import { memo, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Empty } from 'antd'
import { Property } from 'csstype'
import * as d3 from 'd3'

import LoaderPortal from '@components/LoaderPortal'
import { formatDate, makePointsOptimizator, usePartialProps } from '@utils'

import D3MouseZone from './D3MouseZone' 
import {
    BasePluginSettings,
    D3ContextMenu,
    D3ContextMenuSettings,
    D3Cursor,
    D3CursorSettings,
    D3Tooltip,
    D3TooltipSettings
} from './plugins'

import '@styles/d3.less'

type DefaultDataType = Record<string, any>

export type ChartAxis<DataType> = {
    type: 'linear' | 'time',
    accessor: keyof DataType | ((d: DataType) => any)
}

export type BaseChartDataset<DataType> = {
    key: string | number
    label?: ReactNode
    yAxis: ChartAxis<DataType>
    color?: Property.Color
    opacity?: number
    width?: Property.StrokeWidth
    tooltip?: D3TooltipSettings<DataType>
    animDurationMs?: number
    afterDraw?: (d: any) => void
}

export type LineChartDataset<DataType> = {
    type: 'line'
    point?: {
        radius?: number
        color?: Property.Color
    }
    nullValues?: 'skip' | 'gap' | 'none'
    optimization?: boolean
}

export type AreaChartDataset<DataType> = {
    type: 'area'
    fillColor?: Property.Color
    point?: {
        radius?: number
        color?: Property.Color
    }
}

export type NeedleChartDataset<DataType> = {
    type: 'needle'
}

export type ChartDataset<DataType> = BaseChartDataset<DataType> & (
    AreaChartDataset<DataType> |
    LineChartDataset<DataType> |
    NeedleChartDataset<DataType>
)

export type ChartDomain = {
    x: { min?: number, max?: number }
    y: { min?: number, max?: number }
}

export type ChartOffset = {
    top: number
    bottom: number
    left: number
    right: number
}

export type ChartTicks = {
    color?: Property.Color
    x?: { visible?: boolean, count?: number }
    y?: { visible?: boolean, count?: number }
}

export type D3ChartProps<DataType = DefaultDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
    xAxis: ChartAxis<DataType>
    datasets: ChartDataset<DataType>[]
    data?: DataType[]
    domain?: Partial<ChartDomain>
    width?: number | string
    height?: number | string
    loading?: boolean
    offset?: Partial<ChartOffset>
    mode: 'horizontal' | 'vertical'
    animDurationMs?: number
    backgroundColor?: Property.Color
    ticks?: ChartTicks
    plugins?: {
        menu?: BasePluginSettings & D3ContextMenuSettings
        tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
        cursor?: BasePluginSettings & D3CursorSettings
    }
}

type Selection = d3.Selection<any, any, null, undefined>

type ChartRegistry<DataType = DefaultDataType> = ChartDataset<DataType> & {
    (): Selection
    y: (value: any) => number
}

const defaultOffsets: ChartOffset = {
    top: 10,
    bottom: 30,
    left: 50,
    right: 10,
}

const defaultXAxisConfig: ChartAxis<DefaultDataType> = {
    type: 'time',
    accessor: (d: any) => new Date(d.date)
}

const defaultCursorPlugin: BasePluginSettings & D3CursorSettings = {
    enabled: true,
}

const getGroupClass = (key: string | number) => `chart-id-${key}`

const getByAccessor = <T extends Record<string, any>>(accessor: string | ((d: T) => any)) => {
    if (typeof accessor === 'function')
        return accessor
    return (d: T) => d[accessor]
}

const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
    if (config.type === 'time')
        return d3.scaleTime()
    return d3.scaleLinear()
}

export const D3Chart = memo<D3ChartProps>(({
    className = '',
    xAxis: _xAxisConfig,
    datasets,
    data,
    domain,
    width: givenWidth = '100%',
    height: givenHeight = '100%',
    loading,
    offset: _offset,
    mode = 'horizontal',
    animDurationMs = 200,
    backgroundColor = 'transparent',
    ticks,
    plugins,
    ...other
}) => {
    const xAxisConfig = usePartialProps(_xAxisConfig, defaultXAxisConfig)
    const offset = usePartialProps(_offset, defaultOffsets)

    const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
    const [xAxisRef, setXAxisRef] = useState<SVGGElement | null>(null)
    const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
    const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)

    const xAxisArea = useCallback(() => d3.select(xAxisRef), [xAxisRef])
    const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef])
    const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef])

    const getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor])

    const [charts, setCharts] = useState<ChartRegistry[]>([])

    const [rootRef, { width, height }] = useElementSize()

    const xAxis = useMemo(() => {
        if (!data) return

        const xAxis = createAxis(xAxisConfig)

        const [minX, maxX] = d3.extent(data, getX)
        xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
        xAxis.range([0, width - offset.left - offset.right])

        return xAxis
    }, [xAxisConfig, getX, data, domain, width, offset])

    const yAxis = useMemo(() => {
        if (!data) return

        const yAxis = d3.scaleLinear()

        if (domain?.y) {
            const { min, max } = domain.y
            if (min && max && Number.isFinite(min + max)) {
                yAxis.domain([min, max])
                return yAxis
            }
        }

        let minY = Infinity
        let maxY = -Infinity
        charts.forEach(({ y }) => {
            const [min, max] = d3.extent(data, y)
            if (min && min < minY) minY = min
            if (max && max > maxY) maxY = max
        })

        yAxis.domain([
            domain?.y?.min ?? minY,
            domain?.y?.max ?? maxY,
        ])

        yAxis.range([height - offset.top - offset.bottom, 0])

        return yAxis
    }, [charts, data, domain, height, offset])

    useEffect(() => { // Рисуем ось X
        if (!xAxis) return
        xAxisArea().transition()
            .duration(animDurationMs)
            .call(d3.axisBottom(xAxis)
                .tickSize((ticks?.x?.visible ?? false) ? -height + offset.bottom : 0)
                .tickFormat((d) => formatDate(d, undefined, 'YYYY-MM-DD') || 'NaN')
                .ticks(ticks?.x?.count ?? 10) as any // TODO: Исправить тип
            )

        xAxisArea().selectAll('.tick line').attr('stroke', ticks?.color || 'lightgray')
    }, [xAxisArea, xAxis, animDurationMs, height, offset, ticks])

    useEffect(() => { // Рисуем ось Y
        if (!yAxis) return 
        yAxisArea().transition()
            .duration(animDurationMs)
            .call(d3.axisLeft(yAxis)
                .tickSize((ticks?.y?.visible ?? false) ? -width + offset.left + offset.right : 0)
                .ticks(ticks?.y?.count ?? 10) as any // TODO: Исправить тип
            )
        
        yAxisArea().selectAll('.tick line').attr('stroke', ticks?.color || 'lightgray')
    }, [yAxisArea, yAxis, animDurationMs, width, offset, ticks])

    useEffect(() => {
        setCharts((oldCharts) => {
            const charts: ChartRegistry[] = []

            for (const chart of oldCharts) { // Удаляем ненужные графики
                if (datasets.find(({ key }) => key === chart.key))
                    charts.push(chart)
                else
                    chart().remove()
            }

            datasets.forEach((dataset) => { // Добавляем новые
                let chartIdx = charts.findIndex(({ key }) => key === dataset.key)
                if (chartIdx < 0)
                    chartIdx = charts.length

                const newChart: ChartRegistry = Object.assign(
                    () => chartArea().select('.' + getGroupClass(dataset.key)),
                    {
                        ...dataset,
                        y: getByAccessor(dataset.yAxis.accessor)
                    }
                )

                if (!newChart().node())
                    chartArea()
                        .append('g')
                        .attr('class', getGroupClass(newChart.key))

                charts[chartIdx] = newChart
            })

            return charts
        })
    }, [chartArea, datasets])

    const redrawCharts = useCallback(() => {
        if (!data || !xAxis || !yAxis) return

        charts.forEach((chart) => {
            chart()
                .attr('stroke', String(chart.color))
                .attr('stroke-width', chart.width ?? 1)
                .attr('opacity', chart.opacity ?? 1)
                .attr('fill', 'none')

            let d = data

            switch (chart.type) {
                case 'needle': {
                    const bars = chart()
                        .selectAll('line')
                        .data(data)

                    bars.exit().remove()
                    bars.enter().append('line')

                    const newBars = chart()
                        .selectAll('line')
                        .transition()
                        .duration(chart.animDurationMs ?? animDurationMs)
                        .attr('x1', (d: any) => xAxis(getX(d)))
                        .attr('x2', (d: any) => xAxis(getX(d)))
                        .attr('y1', height - offset.bottom - offset.top)
                        .attr('y2', (d: any) => yAxis(chart.y(d)))

                    chart.afterDraw?.(newBars)
                    break
                }
                case 'line': {
                    let line = d3.line()
                        .x(d => xAxis(getX(d)))
                        .y(d => yAxis(chart.y(d)))

                    switch (chart.nullValues || 'skip') {
                        case 'gap':
                            line = line.defined(d => (chart.y(d) ?? null) !== null && !Number.isNaN(chart.y(d)))
                            break
                        case 'skip':
                            d = d.filter(chart.y)
                            break
                        default:
                            break
                    }

                    if (chart.optimization ?? true) {
                        const optimize = makePointsOptimizator((a, b) => chart.y(a) === chart.y(b))
                        d = optimize(d)
                    }

                    if (chart().selectAll('path').empty())
                        chart().append('path')

                    const lineElm = chart().selectAll('path')
                        .transition()
                        .duration(chart.animDurationMs ?? animDurationMs)
                        .attr('d', line(d as any))

                    chart.afterDraw?.(lineElm)
                    break
                }
                default:
                    break
            }
        })
    }, [charts, data, xAxis, yAxis, height])

    useEffect(() => {
        redrawCharts()
    }, [redrawCharts])

    return (
        <LoaderPortal show={loading}>
            <div
                {...other}
                ref={rootRef}
                className={`asb-d3-chart ${className}`}
                style={{
                    width: givenWidth,
                    height: givenHeight,
                }}
            >
                {data ? (
                    <D3ContextMenu {...plugins?.menu} svg={svgRef}>
                        <svg ref={setSvgRef} width={'100%'} height={'100%'}>
                            <g ref={setXAxisRef} className={'axis x'} transform={`translate(${offset.left}, ${height - offset.bottom})`} />
                            <g ref={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
                            <g ref={setChartAreaRef} className={'chart-area'} transform={`translate(${offset.left}, ${offset.top})`}>
                                <rect width={width - offset.left - offset.right} height={height - offset.top - offset.bottom} fill={backgroundColor} />
                            </g>
                            <D3MouseZone width={width} height={height} offset={offset}>
                                {(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} data={data} charts={charts} /> )}
                                {(plugins?.cursor?.enabled ?? true) && ( <D3Cursor {...plugins?.cursor} /> )}
                            </D3MouseZone>
                        </svg>
                    </D3ContextMenu>
                ) : (
                    <div className={'chart-empty'}>
                        <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
                    </div>
                )}
            </div>
        </LoaderPortal>
    )
})

export default D3Chart
