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

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

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

import '@styles/d3.less'
import { ChartAxis, ChartDataset, ChartDomain, ChartOffset, ChartRegistry, ChartTicks, DefaultDataType } from './types'

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

export const D3Chart = memo<D3ChartProps<DefaultDataType>>(({
    className = '',
    xAxis: _xAxisConfig,
    datasets,
    data,
    domain,
    width: givenWidth = '100%',
    height: givenHeight = '100%',
    loading,
    offset: _offset,
    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<DefaultDataType>[]>([])

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

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

        const xAxis = createAxis(xAxisConfig)
        xAxis.range([0, width - offset.left - offset.right])

        if (domain?.x?.min && domain?.x?.max) {
            xAxis.domain([domain.x.min, domain.x.max])
            return xAxis
        }

        if (Array.isArray(data)) {
            const [minX, maxX] = d3.extent(data, getX)
            xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
        } else {
            let [minX, maxX] = [Infinity, -Infinity]
            for (const key in data) {
                const [min, max] = d3.extent(data[key], getX)
                if (min < minX) minX = min
                if (max > maxX) maxX = max
            }
            xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX])
        }

        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
        if (Array.isArray(data)) {
            charts.forEach(({ y }) => {
                const [min, max] = d3.extent(data, y)
                if (min && min < minY) minY = min
                if (max && max > maxY) maxY = max
            })
        } else {
            for (const key in data) {
                const chart = charts.find((chart) => chart.key === key)
                if (!chart) continue
                const [min, max] = d3.extent(data[key], chart.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) => ticks?.x?.format?.(d) ?? String(d))
                .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(() => {
        if (isDev())
            for (let i = 0; i < datasets.length - 1; i++)
                for (let j = i + 1; j < datasets.length; j++)
                    if (datasets[i].key === datasets[j].key)
                        console.warn(`Ключ датасета "${datasets[i].key}" неуникален (индексы ${i} и ${j})!`)

        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,
                        xAxis: dataset.xAxis ?? xAxisConfig,
                        y: getByAccessor(dataset.yAxis.accessor),
                        x: getByAccessor(dataset.xAxis?.accessor ?? xAxisConfig.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('color', chart.color ?? null)
                .attr('stroke', 'currentColor')
                .attr('stroke-width', chart.width ?? 1)
                .attr('opacity', chart.opacity ?? 1)
                .attr('fill', 'none')

            let d = Array.isArray(data) ? data : data[String(chart.key).split(':')[0]]
            if (!d) return
            let elms

            switch (chart.type) {
                case 'needle':
                    elms = chart()
                        .selectAll('line')
                        .data(d)

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

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

                    break
                case 'line': {
                    let line = d3.line()
                        .x(d => xAxis(chart.x(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')

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

                    const radius = chart.point?.radius ?? 3
                    elms = chart()
                        .selectAll('circle')
                        .data(d)

                    elms.exit().remove()
                    elms.enter().append('circle')

                    elms = chart()
                        .selectAll('circle')
                        .transition()
                        .duration(chart.animDurationMs ?? animDurationMs)
                        .attr('cx', (d: any) => xAxis(chart.x(d)))
                        .attr('cy', (d: any) => yAxis(chart.y(d)))
                        .attr('r', radius)
                        .attr('stroke-width', chart.point?.strokeWidth ?? null)
                        .attr('stroke', chart.point?.strokeColor ?? null)
                        .attr('fill', chart.point?.fillColor ?? null)

                    break
                }
                default:
                    break
            }

            chart.afterDraw?.(chart)
        })
    }, [charts, data, xAxis, yAxis, height])

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

    return (
        <LoaderPortal
            show={loading}
            style={{
                width: givenWidth,
                height: givenHeight,
            }}
        >
            <div
                {...other}
                ref={rootRef}
                className={`asb-d3-chart ${className}`}
            >
                {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?.cursor?.enabled ?? true) && ( <D3Cursor {...plugins?.cursor} /> )}
                                {(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} charts={charts} /> )}
                            </D3MouseZone>
                        </svg>
                    </D3ContextMenu>
                ) : (
                    <div className={'chart-empty'}>
                        <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
                    </div>
                )}
            </div>
        </LoaderPortal>
    )
})

export default D3Chart
