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 { isDev, usePartialProps } from '@utils'

import D3MouseZone from './D3MouseZone'
import { getChartClass } from './functions'
import {
    renderArea,
    renderLine,
    renderPoint,
    renderNeedle
} from './renders'
import {
    BasePluginSettings,
    D3ContextMenu,
    D3ContextMenuSettings,
    D3Cursor,
    D3CursorSettings,
    D3Legend,
    D3LegendSettings,
    D3Tooltip,
    D3TooltipSettings,
} from './plugins'
import type {
    ChartAxis,
    ChartDataset,
    ChartDomain,
    ChartOffset,
    ChartRegistry,
    ChartTicks
} from './types'

import '@styles/d3.less'

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

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

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

export type D3ChartProps<DataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
    /** Параметры общей горизонтальной оси */
    xAxis: ChartAxis<DataType>
    /** Параметры графиков */
    datasets: ChartDataset<DataType>[]
    /** Массив отображаемых данных или объект с парами ключ графика-данные */
    data?: DataType[] | Record<string, DataType[]>
    /** Диапозон отображаемых значений */
    domain?: Partial<ChartDomain>
    /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
    width?: string | number
    /** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */
    height?: string | number
    /** Задать статус "заргужается" графику */
    loading?: boolean
    /** Отступы графика от края SVG */
    offset?: Partial<ChartOffset>
    /** Длительность анимации в миллисекундах */
    animDurationMs?: number
    /** Цвет фона в формате CSS-значения */
    backgroundColor?: Property.Color
    /** Настройки рисок и цен деления графика */
    ticks?: ChartTicks<DataType>
    /** Штриховка графика */
    dash?: string | number | number[]
    /** Параметры плагинов */
    plugins?: {
        /** Параметры контекстного меню */
        menu?: BasePluginSettings & D3ContextMenuSettings
        /** Параметры всплывающей подсказки */
        tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
        /** Параметры курсора */
        cursor?: BasePluginSettings & D3CursorSettings
        /** Параметры блока легенды */
        legend?: BasePluginSettings & D3LegendSettings
    }
}

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

const _D3Chart = <DataType extends Record<string, unknown>>({
    className = '',
    xAxis: _xAxisConfig,
    datasets,
    data,
    domain,
    width: givenWidth = '100%',
    height: givenHeight = '100%',
    loading,
    offset: _offset,
    animDurationMs = 200,
    backgroundColor = 'transparent',
    ticks,
    plugins,
    dash,
    ...other
}: D3ChartProps<DataType>) => {
    const xAxisConfig = usePartialProps<ChartAxis<DataType>>(_xAxisConfig, getDefaultXAxisConfig)
    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 [charts, setCharts] = useState<ChartRegistry<DataType>[]>([])

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

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

        const getX = getByAccessor(xAxisConfig.accessor)

        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, 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])

    const nTicks = {
        color: 'lightgray',
        ...ticks,
        x: {
            visible: false,
            format: (d: any) => String(d),
            count: 10,
            ...ticks?.x,
        }
    }

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

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

    useEffect(() => { // Рисуем ось Y
        if (!yAxis) return 

        const nTicks = {
            color: 'lightgray',
            ...ticks,
            y: {
                visible: false,
                format: (d: any) => String(d),
                count: 10,
                ...ticks?.y,
            }
        }

        yAxisArea().transition()
            .duration(animDurationMs)
            .call(d3.axisLeft(yAxis)
                .tickSize(nTicks.y.visible ? -width + offset.left + offset.right : 0)
                .tickFormat((d, i) => nTicks.y.format(d, i))
                .ticks(nTicks.y.count) as any // TODO: Исправить тип
            )
        
        yAxisArea().selectAll('.tick line').attr('stroke', nTicks.y.color || nTicks.color)
    }, [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<DataType>[] = []

            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<DataType> = Object.assign(
                    () => chartArea().select('.' + getChartClass(dataset.key)) as d3.Selection<SVGGElement, DataType, any, unknown>,
                    {
                        width: 1,
                        opacity: 1,
                        label: dataset.key,
                        color: 'gray',
                        animDurationMs,
                        ...dataset,
                        xAxis: dataset.xAxis ?? xAxisConfig,
                        y: getByAccessor(dataset.yAxis.accessor),
                        x: getByAccessor(dataset.xAxis?.accessor ?? xAxisConfig.accessor),
                    }
                )

                if (newChart.type === 'line')
                    newChart.optimization = false

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

                charts[chartIdx] = newChart
            })

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

    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 ?? null)
                .attr('opacity', chart.opacity ?? null)
                .attr('fill', 'none')

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

            switch (chart.type) {
                case 'needle':
                    chartData = renderNeedle<DataType>(xAxis, yAxis, chart, chartData, height, offset)
                    break
                case 'line':
                    chartData = renderLine<DataType>(xAxis, yAxis, chart, chartData)
                    break
                case 'point':
                    chartData = renderPoint<DataType>(xAxis, yAxis, chart, chartData)
                    break
                case 'area':
                    chartData = renderArea<DataType>(xAxis, yAxis, chart, chartData)
                    break
                default:
                    break
            }

            if (chart.point)
                renderPoint<DataType>(xAxis, yAxis, chart, chartData, true)

            if (dash) chart().attr('stroke-dasharray', dash)

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

    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={Math.max(width - offset.left - offset.right, 0)}
                                    height={Math.max(height - offset.top - offset.bottom, 0)}
                                    fill={backgroundColor}
                                />
                            </g>
                            <D3MouseZone width={width} height={height} offset={offset}>
                                <D3Cursor {...plugins?.cursor} />
                                <D3Legend<DataType> charts={charts} {...plugins?.legend} />
                                <D3Tooltip<DataType> charts={charts} {...plugins?.tooltip} />
                            </D3MouseZone>
                        </svg>
                    </D3ContextMenu>
                ) : (
                    <div className={'chart-empty'}>
                        <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
                    </div>
                )}
            </div>
        </LoaderPortal>
    )
}

export const D3Chart = memo(_D3Chart) as typeof _D3Chart

export default D3Chart
