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 {
    ChartAxis,
    ChartDataset,
    ChartOffset,
    ChartRegistry,
    ChartTick,
    MinMax
} from './types'
import {
    BasePluginSettings,
    D3ContextMenu,
    D3ContextMenuSettings,
    D3HorizontalCursor,
    D3HorizontalCursorSettings
} from './plugins'
import D3MouseZone from './D3MouseZone'
import { getByAccessor, getChartClass, getGroupClass, getTicks } from './functions'
import { renderArea, renderLine, renderNeedle, renderPoint, renderRectArea } from './renders'
import D3MonitoringGroupsEditor from './D3MonitoringGroupsEditor'
import { UserSettingsService } from '@asb/services/api'
import { invokeWebApiWrapperAsync } from '../factory'

const roundTo = (v: number, to: number = 50) => {
    if (to == 0) return v
    if (v < 0) return Math.round(v / to) * to
    return Math.ceil(v / to) * to
}

const calculateDomain = (mm: MinMax, round: number = 100): Required<MinMax> => {
    let min = roundTo(mm.min ?? 0, round)
    let max = roundTo(mm.max ?? round, round)
    if (min - max < round) {
        const mid = (min + max) / 2
        min = mid - round
        max = mid + round
    }
    return { min, max }
}

export type ExtendedChartDataset<DataType> = ChartDataset<DataType> & {
    /** Диапазон отображаемых значений по горизонтальной оси */
    xDomain: MinMax
    /** Скрыть отображение шкалы графика */
    hideLabel?: boolean
}

export type ExtendedChartRegistry<DataType> = ChartRegistry<DataType> & ExtendedChartDataset<DataType>

export type ChartGroup<DataType> = {
    /** Получить D3 выборку, содержащую корневой G-элемент группы */
    (): d3.Selection<SVGGElement, any, any, any>
    /** Уникальный ключ группы (индекс) */
    key: number
    /** Массив содержащихся в группе графиков */
    charts: ExtendedChartRegistry<DataType>[]
}

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

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

const getDefaultYTicks = <DataType,>(): Required<ChartTick<DataType>> => ({
    visible: false,
    format: (d: d3.NumberValue, idx: number, data?: DataType) => String(d),
    color: 'lightgray',
    count: 10,
})

/**
 * @template DataType тип данных отображаемых записей
 */
export type D3MonitoringChartsProps<DataType extends Record<string, any>> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
    /** Двумерный массив датасетов (группа-график) */
    datasetGroups: ExtendedChartDataset<DataType>[][]
    /** Ширина графика числом пикселей или CSS-значением (px/%/em/rem) */
    width?: string | number
    /** Высота графика числом пикселей или CSS-значением (px/%/em/rem) */
    height?: string | number
    /** Длительность анимации в миллисекундах */
    animDurationMs?: number
    /** Задать статус "заргужается" графику */
    loading?: boolean
    /** Массив отображаемых данных */
    data?: DataType[]
    /** Отступы графика от края SVG */
    offset?: Partial<ChartOffset>
    /** Цвет фона в формате CSS-значения */
    backgroundColor?: Property.Color
    /** Параметры общей вертикальной оси */
    yAxis?: ChartAxis<DataType>
    /** Параметры плагинов */
    plugins?: {
        /** Параметры горизонтального курсора */
        cursor?: BasePluginSettings & D3HorizontalCursorSettings<DataType>
        /** Параметры контекстного меню */
        menu?: BasePluginSettings & D3ContextMenuSettings
    }
    /** Настройки рисок и цен деления вертикальной шкалы */
    yTicks?: ChartTick<DataType>
    /** Штриховка графика */
    dash?: string | number | number[]
    /** Диапозон отображаемых значений по вертикальной оси (сверху вниз) */
    yDomain?: MinMax
    /** Событие, вызываемое при прокрутке колёсика мышки над графиком */
    onWheel: (e: WheelEvent) => void
    /** Высота шкал графиков в пикселях (20 по умолчанию) */
    axisHeight?: number
    /** Отступ между группами графиков в пикселях (30 по умолчанию) */
    spaceBetweenGroups?: number
    /** Название графика для сохранения в базе */
    chartName?: string
}

export type ChartSizes = ChartOffset & {
    /** Ширина зоны графика */
    inlineWidth: number
    /** Высота зоны графика */
    inlineHeight: number
    /** Ширина группы на графике */
    groupWidth: number
    /** Высота блока осей */
    axesHeight: number
    /** Отступ сверху до активной зоны графиков */
    chartsTop: number
    /** Высота активной зоны графиков */
    chartsHeight: number
    /** Отступ слева для `i`-ой группы */
    groupLeft: (i: number) => number
    /** Отступ сверху для `i`-ой оси в группе размером `count` */
    axisTop: (i: number, count: number) => number
}

type ChartDomain = {
    /** Шкала графика */
    scale: d3.ScaleLinear<number, number>,
    /** Диапазон отображаемых на графике занчений */
    domain: Required<MinMax>,
}

const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
    width: givenWidth = '100%',
    height: givenHeight = '100%',
    animDurationMs = 0,
    loading = false,
    datasetGroups,
    data,
    plugins,
    offset: _offset,
    yAxis: _yAxisConfig,
    backgroundColor = 'transparent',
    yDomain,
    yTicks: _yTicks,
    axisHeight = 20,
    spaceBetweenGroups = 30,
    dash,
    chartName,

    className = '',
    ...other
}: D3MonitoringChartsProps<DataType>) => {
    const [datasets, setDatasets] = useState<ExtendedChartDataset<DataType>[][]>([])
    const [groups, setGroups] = useState<ChartGroup<DataType>[]>([])
    const [svgRef, setSvgRef] = useState<SVGSVGElement | null>(null)
    const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
    const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(null)
    const [axesAreaRef, setAxesAreaRef] = useState<SVGGElement | null>(null)
    const [settingsVisible, setSettingsVisible] = useState<boolean>(true)

    useEffect(() => {
        invokeWebApiWrapperAsync(
            async () => {
                let sets = chartName ? await UserSettingsService.get(chartName) : null
                if (typeof sets === 'string')
                    sets = JSON.parse(sets)
                if (Array.isArray(sets)) {
                    setDatasets(sets)
                } else if (Array.isArray(datasetGroups)) {
                    setDatasets(datasetGroups)
                    if (chartName) {
                        invokeWebApiWrapperAsync(
                            async () => await UserSettingsService.insert(chartName, datasetGroups),
                            undefined,
                            'Не удалось сохранить настройки графиков'
                        )
                    }
                }
            },
            undefined,
            'Не удалось загрузить настройки графиков'
        )
    }, [datasetGroups, chartName])
    
    const offset = usePartialProps(_offset, defaultOffsets)
    const yTicks = usePartialProps<Required<ChartTick<DataType>>>(_yTicks, getDefaultYTicks)
    const yAxisConfig = usePartialProps<ChartAxis<DataType>>(_yAxisConfig, getDefaultYAxisConfig)

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

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

    const sizes: ChartSizes = useMemo(() => {
        const inlineWidth = Math.max(width - offset.left - offset.right, 0)
        const inlineHeight = Math.max(height - offset.top - offset.bottom, 0)
        const groupsCount = groups.length

        const groupWidth = groupsCount ? (inlineWidth - spaceBetweenGroups * (groupsCount - 1)) / groupsCount : 0

        const maxChartCount = Math.max(0, ...groups.map((g) => g.charts.filter((c) => !c.hideLabel).length))
        const axesHeight = (axisHeight * maxChartCount)

        return ({
            ...offset,
            inlineWidth,
            inlineHeight,
            groupWidth,
            axesHeight,
            chartsTop: offset.top + axesHeight,
            chartsHeight: inlineHeight - axesHeight,
            groupLeft: (i: number) => (groupWidth + spaceBetweenGroups) * i,
            axisTop: (i: number, count: number) => axisHeight * (maxChartCount - count + i + 1)
        })
    }, [groups, width, height, offset, axisHeight, spaceBetweenGroups])

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

        const yAxis = d3.scaleTime()
            .domain([yDomain?.min ?? 0, yDomain?.max ?? 0])
            .range([0, sizes.chartsHeight])

        return yAxis
    }, [groups, data, yDomain, sizes.chartsHeight])

    const chartDomains = useMemo(() => groups.map((group) => {
        const out: [string | number, ChartDomain][] = group.charts.map((chart) => {
            const mm = { ...chart.xDomain }
            let domain: Required<MinMax> = { min: 0, max: 100 }
            if (!Number.isNaN((mm.min ?? NaN) + (mm.max ?? NaN))) {
                domain = mm as Required<MinMax>
            } else if (data) {
                const [min, max] = d3.extent(data, chart.x)
                domain = calculateDomain({ min, max, ...mm }, 100)
            }
            return [chart.key, {
                scale: d3.scaleLinear().domain([domain.min, domain.max]),
                domain,
            }]
        })

        out.forEach(([key], i) => {
            const chart = group.charts.find((chart) => chart.key === key)
            const bind = chart?.bindDomainFrom
            if (!bind) return
            const bindDomain = out.find(([key]) => key === bind)
            if (bindDomain)
                out[i][1] = bindDomain[1]
        })

        return Object.fromEntries(out)
    }), [groups, data])

    const createAxesGroup = useCallback((i: number): ChartGroup<DataType> => Object.assign(
        () => chartArea().select('.' + getGroupClass(i)) as d3.Selection<SVGGElement, any, any, any>,
        {
            key: i,
            charts: [],
        }
    ), [chartArea, axesArea])

    const onGroupsChange = useCallback((sets: ExtendedChartDataset<DataType>[][]) => {
        if (chartName) {
            invokeWebApiWrapperAsync(
                async () => {
                    await UserSettingsService.update(chartName, sets)
                },
                undefined,
                'Не удалось сохранить параметры графиков'
            )
        }
        setDatasets(sets)
        setSettingsVisible(false)
    }, [chartName])

    useEffect(() => {
        if (isDev()) {
            datasets.forEach((sets, i) => {
                sets.forEach((set, j) => {
                    for (let k = j + 1; k < sets.length; k++)
                        if (set.key === sets[k].key)
                            console.warn(`Ключ датасета "${set.key}" неуникален (группа ${i}, индексы ${j} и ${k})!`)
                })
            })
        }

        setGroups((oldGroups) => {
            const groups: ChartGroup<DataType>[] = []

            if (datasets.length < oldGroups.length) {
                // Удаляем неактуальные группы
                oldGroups.slice(datasets.length).forEach((group) => group().remove())
                groups.push(...oldGroups.slice(0, datasets.length))
            } else {
                groups.push(...oldGroups)
            }

            datasets.forEach((datasets, i) => {
                let group: ChartGroup<DataType> = createAxesGroup(i)

                if (group().empty())
                    chartArea().append('g')
                        .attr('class', `chart-group ${getGroupClass(i)}`)

                datasets.forEach((dataset) => { // Обновляем и добавляем новые чарты
                    let chartIdx = group.charts.findIndex(({ key }) => key === dataset.key)
                    if (chartIdx < 0) {
                        chartIdx = group.charts.length
                    } else {
                        // Если типы графиков не сходятся удалить старые элементы
                        if (group.charts[chartIdx].type !== dataset.type)
                            group.charts[chartIdx]().selectAll('*').remove()
                    }

                    // Пересоздаём график
                    const newChart: ExtendedChartRegistry<DataType> = Object.assign(
                        () => group().select('.' + getChartClass(dataset.key)) as d3.Selection<SVGGElement, DataType, any, unknown>,
                        {
                            width: 1,
                            opacity: 1,
                            label: dataset.key,
                            color: 'gray',
                            animDurationMs,
                            ...dataset,
                            yAxis: dataset.yAxis ?? yAxisConfig,
                            y: getByAccessor(dataset.yAxis?.accessor ?? yAxisConfig.accessor),
                            x: getByAccessor(dataset.xAxis?.accessor),
                        }
                    )
    
                    if (newChart.type === 'line')
                        newChart.optimization = false
    
                    // Если у графика нет группы создаём её
                    if (newChart().empty())
                        group().append('g')
                            .attr('class', `chart ${getChartClass(newChart.key)}`)

                    group.charts[chartIdx] = newChart
                })

                groups[i] = group
            })

            return groups
        })
    }, [yAxisConfig, chartArea, datasets, animDurationMs, createAxesGroup])

    useEffect(() => {
        const axesGroups = d3.select(axesAreaRef)
            .selectAll('.charts-group')
            .data(groups)

        axesGroups.exit().remove()
        axesGroups.enter()
            .append('g')
            .attr('class', 'charts-group')

        const actualAxesGroups = d3.select(axesAreaRef)
            .selectAll<SVGGElement | null, ChartGroup<DataType>>('.charts-group')
            .attr('class', (g) => `charts-group ${getGroupClass(g.key)}`)
            .attr('transform', (g) => `translate(${sizes.groupLeft(g.key)}, 0)`)

        actualAxesGroups.each(function(group, i) {
            const groupAxes = d3.select(this)
            const chartsData = group.charts.filter((chart) => !chart.hideLabel)
            const charts = groupAxes.selectChildren().data(chartsData)

            charts.exit().remove()
            charts.enter().append('g')
                .attr('class', (d) => `chart ${getChartClass(d.key)}`)
                .attr('transform', (_, j) => `translate(0, ${sizes.axisTop(j, chartsData.length)})`)

            const actualCharts = groupAxes.selectChildren<SVGGElement | null, ExtendedChartRegistry<DataType>>()
                .style('color', (d) => d.color ?? null)

            actualCharts.each(function (chart, j) {
                let axis = d3.axisTop(chartDomains[i][chart.key].scale.range([0, sizes.groupWidth]))
                const domain = chartDomains[i][chart.key].domain

                if (j === chartsData.length - 1) {
                    axis = axis
                        .ticks(5)
                        .tickSize(-sizes.chartsHeight)
                        .tickFormat((d, i) => i === 0 || i === 5 ? String(d) : '')
                        .tickValues(getTicks(domain, 5))
                } else {
                    axis = axis.ticks(1)
                        .tickValues(getTicks(domain, 1))
                }

                d3.select(this).call(axis as any)
            })

            if (actualCharts.selectChild('text').empty())
                actualCharts.append('text')

            actualCharts.selectChild('text')
                .attr('fill', 'currentColor')
                .style('text-anchor', 'middle')
                .style('dominant-baseline', 'middle')
                .attr('x', sizes.groupWidth / 2)
                .attr('y', -axisHeight / 2)
                .text((d) => String(d.label) ?? d.key)

            actualCharts.each(function (_, j) {
                d3.select(this)
                    .selectAll('.tick line')
                    .attr('stroke', j === chartsData.length - 1 ? 'gray' : 'currentColor')
            })
        })
    }, [groups, sizes, spaceBetweenGroups, chartDomains])

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

        const getX = getByAccessor(yAxisConfig.accessor)

        yAxisArea().transition()
            .duration(animDurationMs)
            .call(d3.axisLeft(yAxis)
                .tickFormat((d, i) => {
                    let rowData
                    if (data)
                        rowData = data.find((row) => getX(row) === d)
                    return yTicks.format(d, i, rowData)
                })
                .tickSize(yTicks.visible ? -width + offset.left + offset.right : 0)
                .ticks(yTicks.count) as any // TODO: Исправить тип
            )
        
        yAxisArea().selectAll('.tick line').attr('stroke', yTicks.color)
    }, [yAxisArea, yAxis, animDurationMs, width, offset, yTicks])

    useEffect(() => {
        if (!data || !yAxis) return

        groups.forEach((group, i) => {
            group()
                .attr('transform', `translate(${sizes.groupLeft(group.key)}, 0)`)
                .attr('clip-path', `url(#chart-clip)`)
            
            group.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 = data
                if (!chartData) return

                const xAxis = chartDomains[i][chart.key].scale.range([0, sizes.groupWidth])

                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
                    case 'rect_area':
                        renderRectArea<DataType>(xAxis, yAxis, chart)
                        break
                    default:
                        break
                }
    
                if (chart.point)
                    renderPoint<DataType>(xAxis, yAxis, chart, chartData, true)

                if (dash) chart().attr('stroke-dasharray', dash)
    
                chart.afterDraw?.(chart)
            })
        })
    }, [data, groups, height, offset, sizes, chartDomains])

    return (
        <LoaderPortal
            show={loading}
            style={{
                width: givenWidth,
                height: givenHeight,
            }}
        >
            <div
                {...other}
                ref={rootRef}
                className={`asb-d3-chart ${className}`}
            >
                {data ? (
                    <D3ContextMenu
                        onSettingsOpen={() => setSettingsVisible(true)}
                        {...plugins?.menu}
                        svg={svgRef}
                    >
                        <svg ref={setSvgRef} width={'100%'} height={'100%'}>
                            <defs>
                                <clipPath id={`chart-clip`}>
                                    {/*  Сдвиг во все стороны на 1 px чтобы линии на краях было видно */}
                                    <rect x={-1} y={-1} width={sizes.groupWidth + 2} height={sizes.chartsHeight + 2} />
                                </clipPath>
                            </defs>
                            <g ref={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${sizes.chartsTop})`} />
                            <g ref={setAxesAreaRef} className={'chart-axes'} transform={`translate(${offset.left}, ${offset.top})`}>
                                <rect width={sizes.inlineWidth} height={sizes.axesHeight} fill={backgroundColor} />
                            </g>
                            <g ref={setChartAreaRef} className={'chart-area'} transform={`translate(${offset.left}, ${sizes.chartsTop})`}>
                                <rect width={sizes.inlineWidth} height={sizes.chartsHeight} fill={backgroundColor} />
                            </g>
                            <g stroke={'black'}>
                                {d3.range(1, groups.length).map((i) => {
                                    const x = offset.left + (sizes.groupWidth + spaceBetweenGroups) * i - spaceBetweenGroups / 2
                                    return <line key={`${i}`} x1={x} x2={x} y1={sizes.chartsTop} y2={offset.top + sizes.inlineHeight} />
                                })}
                            </g>
                            <D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}>
                                <D3HorizontalCursor
                                    {...plugins?.cursor}
                                    yAxis={yAxis}
                                    groups={groups}
                                    sizes={sizes}
                                    data={data}
                                />
                            </D3MouseZone>
                        </svg>
                    </D3ContextMenu>
                ) : (
                    <div className={'chart-empty'}>
                        <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
                    </div>
                )}
                <D3MonitoringGroupsEditor
                    name={chartName}
                    groups={datasets}
                    visible={settingsVisible}
                    onChange={onGroupsChange}
                    onCancel={() => setSettingsVisible(false)}
                />
            </div>
        </LoaderPortal>
    )
}

export const D3MonitoringCharts = memo(_D3MonitoringCharts)

export default D3MonitoringCharts
