/*
 * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient
 *
 * http://rtsys.informatik.uni-kiel.de/kieler
 *
 * Copyright 2025 by
 * + Kiel University
 *   + Department of Computer Science
 *     + Real-Time and Embedded Systems Group
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * SPDX-License-Identifier: EPL-2.0
 */

import { inject, injectable } from 'inversify'
import {
    ActionHandlerRegistry,
    IActionDispatcher,
    IActionHandler,
    SetUIExtensionVisibilityAction,
    SGraphImpl,
    TYPES,
} from 'sprotty'
import { Action, Bounds, CenterAction, SetModelAction, UpdateModelAction } from 'sprotty-protocol'
import { KGraphData, SKGraphElement } from '@kieler/klighd-interactive/lib/constraint-classes'
import { createSemanticFilter } from '../filtering/util'
import { SearchBar } from './searchbar'
import { SearchBarPanel } from './searchbar-panel'
import {
    isContainerRendering,
    isKText,
    isRendering,
    isSKGraphElement,
    isSKLabel,
    KRectangle,
    KRendering,
    KText,
    SKEdge,
    SKLabel,
    SKNode,
    SKPort,
} from '../skgraph-models'
import { getReservedStructuralTags } from '../filtering/reserved-structural-tags'
import { SearchResult } from './search-results'
import { SendModelContextAction } from '../actions/actions'

export type ShowSearchBarAction = SetUIExtensionVisibilityAction

/** add UI container */
// eslint-disable-next-line no-redeclare
export namespace ShowSearchBarAction {
    export function create(): ShowSearchBarAction {
        return SetUIExtensionVisibilityAction.create({
            extensionId: SearchBar.ID,
            visible: true,
        })
    }
}

/** hide/unhide the search bar panel */
export interface ToggleSearchBarAction extends Action {
    kind: typeof ToggleSearchBarAction.KIND
    state?: 'show' | 'hide'
    panel: SearchBarPanel
}

// eslint-disable-next-line no-redeclare
export namespace ToggleSearchBarAction {
    export const KIND = 'toggleSearchBar'

    export function create(panel: SearchBarPanel, state?: 'show' | 'hide'): ToggleSearchBarAction {
        return {
            kind: KIND,
            state,
            panel,
        }
    }

    export function isThisAction(action: Action): action is ToggleSearchBarAction {
        return action.kind === KIND
    }
}

export interface UpdateHighlightsAction extends Action {
    kind: typeof UpdateHighlightsAction.KIND
    selectedIndex: number
    previousIndex: number | undefined
    results: SearchResult[]
}

// eslint-disable-next-line no-redeclare
export namespace UpdateHighlightsAction {
    export const KIND = 'updateHighlights'

    export function create(
        currentIndex: number,
        prevIndex: number | undefined,
        results: SearchResult[]
    ): UpdateHighlightsAction {
        return {
            kind: KIND,
            selectedIndex: currentIndex,
            previousIndex: prevIndex,
            results,
        }
    }

    export function isThisAction(action: Action): action is UpdateHighlightsAction {
        return action.kind === KIND
    }
}

export interface ClearHighlightsAction extends Action {
    kind: typeof ClearHighlightsAction.KIND
    results: SearchResult[]
}

// eslint-disable-next-line no-redeclare
export namespace ClearHighlightsAction {
    export const KIND = 'clearHighlights'

    export function create(searchResults: SearchResult[]): ClearHighlightsAction {
        return {
            kind: KIND,
            results: searchResults,
        }
    }

    export function isThisAction(action: Action): action is ClearHighlightsAction {
        return action.kind === KIND
    }
}

// TODO: extract this KRectangle creation to a dedicated JS KGraph creation library
function createHighlightRectangle(elem: SKGraphElement, bounds: Bounds, highlight: number): KRectangle {
    return {
        type: 'KRectangleImpl',
        id: `highlightRect-${elem.id}`,
        children: [],
        properties: {
            'klighd.rendering.highlight': highlight,
            'klighd.lsp.calculated.bounds': bounds,
        },
        actions: [],
        styles: [],
    }
}

export interface RetrieveTagsActions extends Action {
    kind: typeof RetrieveTagsAction.KIND
}

export namespace RetrieveTagsAction {
    export const KIND = 'retrieveTags'

    export function create(): RetrieveTagsActions {
        return {
            kind: KIND,
        }
    }

    export function isThisAction(action: Action): action is RetrieveTagsActions {
        return action.kind === KIND
    }
}

export interface SearchAction extends Action {
    kind: typeof SearchAction.KIND
    id: string
    textInput: string
    tagInput: string
}

// eslint-disable-next-line no-redeclare
export namespace SearchAction {
    export const KIND = 'handleSearch'

    export function create(id: string, textInput: string, tagInput: string): SearchAction {
        return {
            kind: KIND,
            id,
            textInput,
            tagInput,
        }
    }

    export function isThisAction(action: Action): action is SearchAction {
        return action.kind === KIND
    }
}

@injectable()
export class SearchBarActionHandler implements IActionHandler {
    private static currentModel?: SKGraphElement

    private OPACITY_INCREMENT: number = 2

    private HIGHLIGHT_MATCH: number = 2

    private HIGHLIGHT_MAIN_MATCH: number = 1

    private panel: SearchBarPanel

    private modelChanged: boolean = false

    // TODO: ktexts can't have a border, so instead of setting highlight directly on the ktext, a rectangle with the correct
    //       size should be added behind it instead (this does pose an additional issue with the foreground then not being
    //       applied to the text itself, so it's a choice, support border or support foreground highlight)

    @inject(TYPES.IActionDispatcher) private actionDispatcher: IActionDispatcher

    initialize(registry: ActionHandlerRegistry): void {
        registry.register(SetModelAction.KIND, this)
        registry.register(UpdateModelAction.KIND, this)
        registry.register(SendModelContextAction.KIND, this)
        registry.register(ToggleSearchBarAction.KIND, this)
        registry.register(SearchAction.KIND, this)
        registry.register(ClearHighlightsAction.KIND, this)
        registry.register(UpdateHighlightsAction.KIND, this)
        registry.register(RetrieveTagsAction.KIND, this)
    }

    handle(action: Action): void {
        /* Intercept model from rendering step */
        if (action.kind === SendModelContextAction.KIND) {
            const root: SGraphImpl = (action as SendModelContextAction).model
            if (root.type !== 'graph') {
                return
            }
            SearchBarActionHandler.currentModel = root as unknown as SKGraphElement
            if (this.panel?.isVisible && this.modelChanged) {
                // only retrigger the search if the model changed since the last search
                this.modelChanged = false
                this.actionDispatcher.dispatch(
                    SearchAction.create(SearchBar.ID, this.panel.textInput ?? '', this.panel.tagSearch ?? '')
                )
            }
            return
        }
        if (action.kind === SetModelAction.KIND || action.kind === UpdateModelAction.KIND) {
            this.modelChanged = true
            return
        }

        if (!SearchBarActionHandler.currentModel) return

        const modelId = SearchBarActionHandler.currentModel?.id

        if (ToggleSearchBarAction.isThisAction(action)) {
            if (!this.panel) {
                this.panel = action.panel
            }

            const newVisible = action.state === 'show'

            if (this.panel.isVisible !== newVisible) {
                this.panel.changeVisibility(newVisible)
                this.panel.update()
            }
        } else if (ClearHighlightsAction.isThisAction(action)) {
            /* Handle ClearHighlightsActions */
            this.removeHighlights(action.results)
            // make changes visible
            if (modelId && this.actionDispatcher) {
                this.actionDispatcher.dispatch(CenterAction.create([modelId]))
            }
        } else if (UpdateHighlightsAction.isThisAction(action)) {
            /* Update highlights to show current result orange  */
            if (action.selectedIndex === undefined || !action.results || !this.panel) return
            this.updateHighlights(action.selectedIndex, action.previousIndex, action.results)
            if (modelId && this.actionDispatcher) {
                this.actionDispatcher.dispatch(CenterAction.create([modelId]))
            }
        } else if (RetrieveTagsAction.isThisAction(action)) {
            /* searches for all tags on the model */
            if (!this.panel) return
            this.retrieveTags(SearchBarActionHandler.currentModel)
        } else if (SearchAction.isThisAction(action)) {
            /* Handle search itself */
            const query = action.textInput.trim().toLowerCase()
            const tagQuery = action.tagInput

            const results: SearchResult[] = this.searchModel(SearchBarActionHandler.currentModel, query, tagQuery)

            this.highlightSearchResults(results)
            this.updateHighlights(this.panel.getLastActiveIndex, undefined, results)
            if (modelId && this.actionDispatcher) {
                this.actionDispatcher.dispatch(CenterAction.create([modelId]))
            }

            this.panel.setResults(results)
            this.panel.update()
        }
    }

    /**
     * Looks for all tags on the current graph to display them on the panel.
     * @param root the model
     * @param panel the search bar panel
     */
    private retrieveTags(root: SKGraphElement): void {
        const results = this.searchModel(root, '', 'true').map((result) => result.element)
        if (!results) return

        const seenTags = new Set<string>()
        const tags: { tag: string; num?: number }[] = getReservedStructuralTags().map((tag) => ({ tag }))

        const collectFrom = (obj: SKGraphElement | KRendering) => {
            const tagProp = obj?.properties?.['de.cau.cs.kieler.klighd.semanticFilter.tags']
            if (Array.isArray(tagProp)) {
                for (const item of tagProp) {
                    if (typeof item.tag === 'string') {
                        const { tag } = item
                        if (!seenTags.has(tag)) {
                            seenTags.add(tag)
                            tags.push({ tag, num: item.num })
                        }
                    }
                }
            }
        }

        while (results.length > 0) {
            const currentElem = results.shift()!

            collectFrom(currentElem)

            if (Array.isArray(currentElem.data)) {
                for (const child of currentElem.data) {
                    if (isRendering(child)) {
                        collectFrom(child)
                    }
                }
            }
        }

        tags.sort((a, b) => a.tag.localeCompare(b.tag))
        this.panel.setTags(tags)
    }

    /**
     * Remove all highlights
     * @param results the highlighted results
     */
    private removeHighlights(searchResults: SearchResult[]): void {
        for (const result of searchResults) {
            this.removeSpecificHighlight(result)
        }
    }

    /**
     * Remove a specific highlight
     * @param searchResult the search result for which to remove the highlight
     */
    private removeSpecificHighlight(searchResult: SearchResult) {
        const elemID = searchResult.element.id

        const { element, kText } = searchResult

        if (kText) {
            kText.properties['klighd.rendering.highlight'] = 0
        }

        if (isContainerRendering(element)) {
            element.removeAll((child) => !child.id?.includes(`highlightRect-${elemID}`))
        }

        const { data } = element
        for (const item of data) {
            if (isContainerRendering(item)) {
                item.children = item.children.filter(
                    (child: { id: string }) => !child.id?.includes(`highlightRect-${elemID}`)
                )
            } else if (isRendering(item)) {
                item.properties['klighd.rendering.highlight'] = 0
            }
        }
    }

    /**
     * Adds highlighting to labels or nodes
     * @param searchResult the the search result that shall be highlighted
     * @param highlight the type of highlight (HIGHLIGHT_MATCH, HIGHLIGHT_MAIN_MATCH, +OPACITY_INCREMENT)
     */
    private addHighlightToElement(searchResult: SearchResult, highlight: number): void {
        const bounds = this.extractBounds(searchResult.element)
        const { data } = searchResult.element
        if (data !== undefined) {
            for (const item of data) {
                if (isContainerRendering(item)) {
                    const alreadyHasHighlight = item.children?.some((child) => child.id?.startsWith('highlightRect-'))
                    if (!alreadyHasHighlight) {
                        const highlightRect = createHighlightRectangle(
                            searchResult.element,
                            bounds,
                            highlight + this.OPACITY_INCREMENT
                        )
                        item.children = [...(item.children ?? []), highlightRect]
                    }
                } else if (isKText(item)) {
                    item.properties['klighd.rendering.highlight'] = highlight
                }
            }
        }
    }

    /**
     * Highlights the selectedIndex-th result orange and keeps the other indices yellow
     * @param selectedIndex the results the user is currenlty panned to.
     * @param lastIndex the previous selectedIndex (currently orange -> needs to be yellow)
     * @param results the search results
     */
    private updateHighlights(selectedIndex: number, lastIndex: number | undefined, results: SearchResult[]): void {
        if (selectedIndex >= results.length) return
        if (selectedIndex === lastIndex) return

        this.removeSpecificHighlight(results[selectedIndex])

        const lastElem = lastIndex !== undefined ? results[lastIndex] : undefined
        if (lastElem) this.removeSpecificHighlight(lastElem)

        if (this.panel.textInput === '') {
            if (lastElem) this.addHighlightToElement(lastElem, this.HIGHLIGHT_MATCH)
            this.addHighlightToElement(results[selectedIndex], this.HIGHLIGHT_MAIN_MATCH)
        } else {
            if (results[selectedIndex].kText) {
                results[selectedIndex].kText!.properties['klighd.rendering.highlight'] = this.HIGHLIGHT_MAIN_MATCH
            } else {
                this.addHighlightToElement(results[selectedIndex], this.HIGHLIGHT_MAIN_MATCH)
            }
            if (lastElem) {
                if (lastElem.kText) {
                    lastElem.kText.properties['klighd.rendering.highlight'] = this.HIGHLIGHT_MATCH
                } else {
                    this.addHighlightToElement(lastElem, this.HIGHLIGHT_MATCH)
                }
            }
        }
    }

    /**
     * Highlights all search results
     * @param results the search results to highlight
     */
    private highlightSearchResults(results: SearchResult[]) {
        for (const result of results) {
            if (result.kText) {
                result.kText.properties['klighd.rendering.highlight'] = this.HIGHLIGHT_MATCH
            } else {
                this.addHighlightToElement(result, this.HIGHLIGHT_MATCH)
            }
        }
    }

    /**
     * Checks if text matches query and possibly adds the element to results with highlighting
     * @param parent the graph element containing the text
     * @param element the rendering containing the text or the label itself
     * @param query the user input
     * @param bounds the position and size of the possible highlight
     * @param results the array containing all results
     * @param textRes the array containing all {@param text} matches
     */
    private processTextMatch(
        parent: SKGraphElement,
        element: KText | SKLabel,
        query: string,
        filter: (el: SKGraphElement) => boolean,
        results: SearchResult[],
        regex: RegExp | undefined
    ): void {
        const { text } = element as unknown as KText
        const matches = regex ? regex.test(text) : text.toLowerCase().includes(query)

        if (matches && filter(parent)) {
            const result = new SearchResult(parent, undefined, text)
            if (isKText(element)) {
                result.kText = element
            }
            results.push(result)
        }
    }

    /**
     * Add an element to the results and highlight it.
     * @param element the graph element
     * @param results the array containing all results
     * @param textRes the array containing all text matches
     */
    private processElement(element: SKGraphElement, results: SearchResult[]) {
        const name = this.extractDisplayName(element)
        const result = new SearchResult(element, undefined, name)
        results.push(result)
    }

    /**
     * Extracts bounds from an element
     * @param element the element, whose bounds need to be extracted
     */
    private extractBounds(element: SKGraphElement): Bounds {
        if (element instanceof SKNode || element instanceof SKPort || element instanceof SKLabel) {
            return { x: 0, y: 0, width: element.bounds.width, height: element.bounds.height }
        }
        if (element instanceof SKEdge) {
            let minX = Number.MAX_VALUE
            let minY = Number.MAX_VALUE
            let maxX = Number.MIN_VALUE
            let maxY = Number.MIN_VALUE
            for (const point of element.routingPoints) {
                if (point.x < minX) {
                    minX = point.x
                }
                if (point.y < minY) {
                    minY = point.y
                }
                if (point.x > maxX) {
                    maxX = point.x
                }
                if (point.y > maxY) {
                    maxY = point.y
                }
            }
            const PADDING = 5
            return {
                x: minX - PADDING,
                y: minY - PADDING,
                width: maxX - minX + 2 * PADDING,
                height: maxY - minY + 2 * PADDING,
            }
        }

        return { x: -1, y: -1, width: -1, height: -1 }
    }

    /**
     * Helper function for regular expressions
     * @param query the user input
     * @returns a parsed regular expression or an error
     */
    private compileRegex(query: string): RegExp | undefined {
        try {
            return new RegExp(query, 'i')
        } catch (e) {
            const errorMessage = e instanceof Error ? e.message : String(e)
            this.panel.setError(errorMessage)
            return undefined
        }
    }

    /**
     * Perform a breadth-first search on {@param root} to find {@param query}
     * @param root the model
     * @param query the user input
     * @returns array of results
     */
    private searchModel(root: SKGraphElement, query: string, tagQuery: string): SearchResult[] {
        const results: SearchResult[] = []
        const regex = this.panel.isRegex ? this.compileRegex(query) : undefined
        const lowerQuery = query.toLowerCase()
        const queue: (SKGraphElement | KRendering)[] = [root as SKGraphElement]

        if (query === '' && tagQuery === '') {
            return results
        }

        let filter: (el: SKGraphElement) => boolean = () => true
        if (tagQuery !== '') {
            filter = createSemanticFilter(tagQuery)
        }

        while (queue.length > 0) {
            const element = queue.shift()!

            if (query === '') {
                /* add all elements if text query is empty */
                if (isSKGraphElement(element)) {
                    try {
                        if (filter(element)) {
                            this.processElement(element, results)
                        }
                    } catch (e) {
                        const errorMessage = e instanceof Error ? e.message : String(e)
                        this.panel.setError(errorMessage)
                        return results
                    }
                }
            } else {
                /* handle elements with text field */
                if (isSKLabel(element)) {
                    const { text } = element
                    if (text.trim()) {
                        this.processTextMatch(element, element, lowerQuery, filter, results, regex)
                    }
                }

                /* Process data field for renderings */
                if (isSKGraphElement(element)) {
                    const dataArr: KGraphData[] = element.data
                    if (dataArr.length > 0) {
                        const data = dataArr[0]
                        if (isContainerRendering(data)) {
                            for (const child of data.children) {
                                this.visitRendering(child, element, lowerQuery, filter, results, regex)
                            }
                        } else if (isRendering(data)) {
                            this.visitRendering(data, element, lowerQuery, filter, results, regex)
                        }
                    }
                }
            }

            /* Add children to queue */
            if (isContainerRendering(element) || isSKGraphElement(element) || element.type === 'graph') {
                // TODO: bad don't do this
                for (const child of (element as any).children) {
                    queue.push(child as SKGraphElement | KRendering)
                }
            }
        }

        return results
    }

    /**
     * Go into a rendering to look for the text field and compare it to the input
     * @param rendering KText or KLabel
     * @param parent KContainerRendering that contains {@param rendering}
     * @param query the query string
     * @param filter the filter function
     * @param results the results object to store search results in
     * @param regex a regex if there is one
     */
    private visitRendering(
        rendering: KRendering,
        parent: SKGraphElement,
        query: string,
        filter: (el: SKGraphElement) => boolean,
        results: SearchResult[],
        regex: RegExp | undefined
    ): void {
        if (!rendering) return

        /* Check KText */
        if (isKText(rendering) && rendering.text) {
            this.processTextMatch(parent, rendering, query, filter, results, regex)
        }

        /* Check KContainerElements */
        if (isContainerRendering(rendering)) {
            for (const child of rendering.children ?? []) {
                this.visitRendering(child, parent, query, filter, results, regex)
            }
        }
    }

    /**
     * Finds a name to display for nodes that meet the searched tags
     * @param element the node
     * @returns name for result list
     */
    private extractDisplayName(element: SKGraphElement): string {
        const segments = element.id.split('$')
        for (let i = segments.length - 1; i >= 0; i--) {
            const segment = segments[i]
            if (segment?.length > 1) {
                switch (segment.charAt(0)) {
                    case 'N':
                    case 'E':
                    case 'P':
                    case 'L':
                        return segment.substring(1)
                    default:
                        break
                }
            }
        }
        return element.id
    }
}
