import { vec3 } from 'gl-matrix'
import { NVMesh, MeshType } from '@/nvmesh'
import { NVUtilities } from '@/nvutilities'
import { NiivueObject3D } from '@/niivue-object3D'
import { NVMeshUtilities } from '@/nvmesh-utilities'
import { cmapper } from '@/colortables'
import { NVLabel3D, LabelTextAlignment, LabelLineTerminator } from '@/nvlabel'
import { Connectome, ConnectomeOptions, LegacyConnectome, NVConnectomeEdge, NVConnectomeNode } from '@/types'
import { log } from '@/logger'

const defaultOptions: ConnectomeOptions = {
    name: 'untitled connectome',
    nodeColormap: 'warm',
    nodeColormapNegative: 'winter',
    nodeMinColor: 0,
    nodeMaxColor: 4,
    nodeScale: 3,
    edgeColormap: 'warm',
    edgeColormapNegative: 'winter',
    edgeMin: 2,
    edgeMax: 6,
    edgeScale: 1,
    legendLineThickness: 0,
    showLegend: true
}

export type FreeSurferConnectome = {
    data_type: string
    points: Array<{
        comments?: Array<{
            text: string
        }>
        coordinates: {
            x: number
            y: number
            z: number
        }
    }>
}

/**
 * Represents a connectome
 */
export class NVConnectome extends NVMesh {
    gl: WebGL2RenderingContext
    nodesChanged: EventTarget

    constructor(gl: WebGL2RenderingContext, connectome: LegacyConnectome) {
        super(new Float32Array([]), new Uint32Array([]), connectome.name, new Uint8Array([]), 1.0, true, gl, connectome)
        this.gl = gl
        // this.nodes = connectome.nodes;
        // this.edges = connectome.edges;
        // this.options = { ...defaultOptions, ...connectome };
        this.type = MeshType.CONNECTOME
        if (this.nodes) {
            this.updateLabels()
        }
        this.nodesChanged = new EventTarget()
    }

    static convertLegacyConnectome(json: LegacyConnectome): Connectome {
        const connectome: Connectome = { nodes: [], edges: [], ...defaultOptions }
        for (const prop in json) {
            if (prop in defaultOptions) {
                const key = prop as keyof ConnectomeOptions
                // @ts-expect-error -- this will work, as both extend ConnectomeOptions
                connectome[key] = json[key]
            }
        }
        const nodes = json.nodes
        for (let i = 0; i < nodes.names.length; i++) {
            connectome.nodes.push({
                name: nodes.names[i],
                x: nodes.X[i],
                y: nodes.Y[i],
                z: nodes.Z[i],
                colorValue: nodes.Color[i],
                sizeValue: nodes.Size[i]
            })
        }
        for (let i = 0; i < nodes.names.length - 1; i++) {
            for (let j = i + 1; j < nodes.names.length; j++) {
                const colorValue = json.edges[i * nodes.names.length + j]
                connectome.edges.push({
                    first: i,
                    second: j,
                    colorValue
                })
            }
        }

        return connectome
    }

    static convertFreeSurferConnectome(json: FreeSurferConnectome, colormap = 'warm'): Connectome {
        let isValid = true
        if (!('data_type' in json)) {
            isValid = false
        } else if (json.data_type !== 'fs_pointset') {
            isValid = false
        }
        if (!('points' in json)) {
            isValid = false
        }
        if (!isValid) {
            throw Error('not a valid FreeSurfer json pointset')
        }

        const nodes = json.points.map((p) => ({
            name: Array.isArray(p.comments) && p.comments.length > 0 && 'text' in p.comments[0] ? p.comments[0].text : '',
            x: p.coordinates.x,
            y: p.coordinates.y,
            z: p.coordinates.z,
            colorValue: 1,
            sizeValue: 1,
            metadata: p.comments
        }))
        const connectome = {
            ...defaultOptions,
            nodeColormap: colormap,
            edgeColormap: colormap,
            nodes,
            edges: []
        }
        return connectome
    }

    updateLabels(): void {
        const nodes = this.nodes as NVConnectomeNode[]
        if (nodes && nodes.length > 0) {
            // largest node
            const largest = (nodes as NVConnectomeNode[]).reduce((a, b) => (a.sizeValue > b.sizeValue ? a : b)).sizeValue
            let min, max

            // Determine the minimum color value
            if (typeof this.nodeMinColor !== 'undefined' && isFinite(this.nodeMinColor)) {
                min = this.nodeMinColor
            } else {
                min = nodes[0].colorValue // Initialize min to the first node's colorValue
                for (let i = 1; i < nodes.length; i++) {
                    if (nodes[i].colorValue < min) {
                        min = nodes[i].colorValue
                    }
                }
            }
            // Determine the maximum color value
            if (typeof this.nodeMaxColor !== 'undefined' && isFinite(this.nodeMaxColor)) {
                max = this.nodeMaxColor
            } else {
                max = nodes[0].colorValue // Initialize max to the first node's colorValue
                for (let i = 1; i < nodes.length; i++) {
                    if (nodes[i].colorValue > max) {
                        max = nodes[i].colorValue
                    }
                }
            }
            const lut = cmapper.colormap(this.nodeColormap, this.colormapInvert)
            const lutNeg = cmapper.colormap(this.nodeColormapNegative, this.colormapInvert)
            const hasNeg = 'nodeColormapNegative' in this
            let legendLineThickness = this.legendLineThickness ? this.legendLineThickness : 0.0

            if (this.showLegend === false) {
                legendLineThickness = 0
            }
            for (let i = 0; i < nodes.length; i++) {
                let color = nodes[i].colorValue
                let isNeg = false
                if (hasNeg && color < 0) {
                    isNeg = true
                    color = -color
                }

                if (min < max) {
                    if (color < min) {
                        log.warn('color value lower than min')
                        continue
                    }
                    color = (color - min) / (max - min)
                } else {
                    color = 1.0
                }

                color = Math.round(Math.max(Math.min(255, color * 255))) * 4
                let rgba = [lut[color], lut[color + 1], lut[color + 2], 255]
                if (isNeg) {
                    rgba = [lutNeg[color], lutNeg[color + 1], lutNeg[color + 2], 255]
                }
                rgba = rgba.map((c) => c / 255)
                log.debug('adding label for ', nodes[i])
                nodes[i].label = new NVLabel3D(
                    nodes[i].name,
                    {
                        textColor: rgba,
                        bulletScale: nodes[i].sizeValue / largest,
                        bulletColor: rgba,
                        lineWidth: legendLineThickness,
                        lineColor: rgba,
                        textScale: 1.0,
                        textAlignment: LabelTextAlignment.LEFT,
                        lineTerminator: LabelLineTerminator.NONE
                    },
                    [nodes[i].x, nodes[i].y, nodes[i].z]
                )
                log.debug('label for node:', nodes[i].label)
            }
        }
    }

    addConnectomeNode(node: NVConnectomeNode): void {
        log.debug('adding node', node)
        if (!this.nodes) {
            throw new Error('nodes not defined')
        }

        ;(this.nodes as NVConnectomeNode[]).push(node)
        this.updateLabels()
        this.nodesChanged.dispatchEvent(new CustomEvent('nodeAdded', { detail: { node } }))
    }

    deleteConnectomeNode(node: NVConnectomeNode): void {
        // delete any connected edges
        const index = (this.nodes as NVConnectomeNode[]).indexOf(node)
        const edges = this.edges as NVConnectomeEdge[]
        if (edges) {
            this.edges = edges.filter((e) => e.first !== index && e.second !== index)
        }
        this.nodes = (this.nodes as NVConnectomeNode[]).filter((n) => n !== node)

        this.updateLabels()
        this.updateConnectome(this.gl)
        this.nodesChanged.dispatchEvent(new CustomEvent('nodeDeleted', { detail: { node } }))
    }

    updateConnectomeNodeByIndex(index: number, updatedNode: NVConnectomeNode): void {
        ;(this.nodes as NVConnectomeNode[])[index] = updatedNode
        this.updateLabels()
        this.updateConnectome(this.gl)
        this.nodesChanged.dispatchEvent(new CustomEvent('nodeChanged', { detail: { node: updatedNode } }))
    }

    updateConnectomeNodeByPoint(point: [number, number, number], updatedNode: NVConnectomeNode): void {
        // TODO this was updating nodes in this.connectome.nodes
        const nodes = this.nodes as NVConnectomeNode[]
        if (!nodes) {
            throw new Error('Node to update does not exist')
        }
        const node = nodes.find((node) => NVUtilities.arraysAreEqual([node.x, node.y, node.z], point))
        if (!node) {
            throw new Error(`Node with point ${point} to update does not exist`)
        }
        const index = nodes.findIndex((n) => n === node)
        this.updateConnectomeNodeByIndex(index, updatedNode)
    }

    addConnectomeEdge(first: number, second: number, colorValue: number): NVConnectomeEdge {
        const edges = this.edges as NVConnectomeEdge[]
        let edge = edges.find((f) => (f.first === first || f.second === first) && f.first + f.second === first + second)
        if (edge) {
            return edge
        }
        edge = { first, second, colorValue }
        edges.push(edge)
        this.updateConnectome(this.gl)
        return edge
    }

    deleteConnectomeEdge(first: number, second: number): NVConnectomeEdge {
        const edges = this.edges as NVConnectomeEdge[]

        const edge = edges.find((f) => (f.first === first || f.first === second) && f.first + f.second === first + second)
        if (edge) {
            this.edges = edges.filter((e) => e !== edge)
        } else {
            throw new Error(`edge between ${first} and ${second} not found`)
        }
        this.updateConnectome(this.gl)
        return edge
    }

    findClosestConnectomeNode(point: number[], distance: number): NVConnectomeNode | null {
        const nodes = this.nodes as NVConnectomeNode[]
        if (!nodes || nodes.length === 0) {
            return null
        }

        const closeNodes = nodes
            .map((n, i) => ({
                node: n,
                distance: Math.sqrt(Math.pow(n.x - point[0], 2) + Math.pow(n.y - point[1], 2) + Math.pow(n.z - point[2], 2)),
                index: i
            }))
            .filter((n) => n.distance < distance)
            .sort((a, b) => a.distance - b.distance)
        if (closeNodes.length > 0) {
            return closeNodes[0].node
        } else {
            return null
        }
    }

    updateConnectome(gl: WebGL2RenderingContext): void {
        const tris: number[] = []
        const pts: number[] = []
        const rgba255: number[] = []
        let lut = cmapper.colormap(this.nodeColormap, this.colormapInvert)
        let lutNeg = cmapper.colormap(this.nodeColormapNegative, this.colormapInvert)
        let hasNeg = 'nodeColormapNegative' in this

        // issue1080 we can have nodes without edges, so edgeMin/Max need not be defined
        if (this.nodeMinColor === undefined) {
            this.nodeMinColor = NaN
        }
        if (this.nodeMaxColor === undefined) {
            this.nodeMaxColor = NaN
        }
        // issue1080 we can have nodes without edges, so edgeMin/Max need not be defined

        if (this.edgeMin === undefined) {
            this.edgeMin = NaN
        }
        if (this.edgeMax === undefined) {
            this.edgeMax = NaN
        }
        let min = this.nodeMinColor
        let max = this.nodeMaxColor
        if (!isFinite(min) || !isFinite(min)) {
            const nodes = this.nodes as NVConnectomeNode[]
            min = nodes[0].colorValue
            max = nodes[0].colorValue
            for (let i = 0; i < nodes.length; i++) {
                min = Math.min(min, nodes[i].colorValue)
                max = Math.max(max, nodes[i].colorValue)
            }
        }
        // TODO these statements can be removed once the node types are cleaned up
        const nodes = this.nodes as NVConnectomeNode[]
        const nNode = nodes.length
        for (let i = 0; i < nNode; i++) {
            const radius = nodes[i].sizeValue * this.nodeScale
            if (radius <= 0.0) {
                continue
            }
            let color = nodes[i].colorValue
            let isNeg = false
            if (hasNeg && color < 0) {
                isNeg = true
                color = -color
            }
            if (min < max) {
                if (color < min) {
                    continue
                }
                color = (color - min) / (max - min)
            } else {
                color = 1.0
            }
            color = Math.round(Math.max(Math.min(255, color * 255))) * 4
            let rgba = [lut[color], lut[color + 1], lut[color + 2], 255]
            if (isNeg) {
                rgba = [lutNeg[color], lutNeg[color + 1], lutNeg[color + 2], 255]
            }
            const pt = vec3.fromValues(nodes[i].x, nodes[i].y, nodes[i].z)

            NiivueObject3D.makeColoredSphere(pts, tris, rgba255, radius, pt, rgba)
        }

        lut = cmapper.colormap(this.edgeColormap, this.colormapInvert)
        lutNeg = cmapper.colormap(this.edgeColormapNegative, this.colormapInvert)
        hasNeg = 'edgeColormapNegative' in this
        // TODO fix edge types
        const edges = this.edges as NVConnectomeEdge[]
        if (edges !== undefined && edges.length > 0) {
            min = this.edgeMin
            max = this.edgeMax
            // issue 1080: autodetect range
            if (!isFinite(min) || !isFinite(min)) {
                min = edges[0].colorValue
                max = edges[0].colorValue
                for (let i = 0; i < edges.length; i++) {
                    min = Math.min(min, edges[i].colorValue)
                    max = Math.max(max, edges[i].colorValue)
                }
            }
            for (const edge of edges) {
                let color = edge.colorValue
                const isNeg = hasNeg && color < 0
                if (isNeg) {
                    color = -color
                }
                const radius = color * this.edgeScale
                if (radius <= 0) {
                    continue
                }
                if (min < max) {
                    if (color < min) {
                        continue
                    }
                    color = (color - min) / (max - min)
                } else {
                    color = 1.0
                }
                color = Math.round(Math.max(Math.min(255, color * 255))) * 4
                let rgba = [lut[color], lut[color + 1], lut[color + 2], 255]
                if (isNeg) {
                    rgba = [lutNeg[color], lutNeg[color + 1], lutNeg[color + 2], 255]
                }
                const pti = vec3.fromValues(nodes[edge.first].x, nodes[edge.first].y, nodes[edge.first].z)
                const ptj = vec3.fromValues(nodes[edge.second].x, nodes[edge.second].y, nodes[edge.second].z)
                NiivueObject3D.makeColoredCylinder(pts, tris, rgba255, pti, ptj, radius, rgba)
            }
        }

        const pts32 = new Float32Array(pts)
        const tris32 = new Uint32Array(tris)
        // calculate spatial extent of connectome: user adjusting node sizes may influence size
        const obj = NVMeshUtilities.getExtents(pts32)

        this.furthestVertexFromOrigin = obj.mxDx
        this.extentsMin = obj.extentsMin
        this.extentsMax = obj.extentsMax
        const posNormClr = this.generatePosNormClr(pts32, tris32, new Uint8Array(rgba255))
        // generate webGL buffers and vao
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer)
        gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Uint32Array.from(tris32), gl.STATIC_DRAW)
        gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer)
        gl.bufferData(gl.ARRAY_BUFFER, Float32Array.from(posNormClr), gl.STATIC_DRAW)
        this.indexCount = tris.length
    }

    updateMesh(gl: WebGL2RenderingContext): void {
        this.updateConnectome(gl)
        this.updateLabels()
    }

    json(): Connectome {
        const json: Partial<Connectome> = {}
        for (const prop in this) {
            if (prop in defaultOptions || prop === 'nodes' || prop === 'edges') {
                // @ts-expect-error this is not very ethical; returning every field explicitly would probably be better
                json[prop as keyof Connectome] = this[prop]
            }
        }
        return json as Connectome
    }

    /**
     * Factory method to create connectome from options
     */
    static async loadConnectomeFromUrl(gl: WebGL2RenderingContext, url: string): Promise<NVConnectome> {
        const response = await fetch(url)
        const json = await response.json()
        return new NVConnectome(gl, json)
    }
}
