/**
 * Find the largest value among the provided data points.
 *
 * @param {Array} data - An array of columns with associated values.
 * @returns {number} - The largest value found in the data.
 */
function getLargest(data) {
    // Initialize largest with the first value of the first category
    let largest = +data[0].values[0]

    // Iterate through each category(column)
    data.forEach(categ => {
        // Iterate through the values of each category
        for (let i = 0; i < categ.values.length; i++)
            // Convert the value to a number and compare with the current largest
            if (+categ.values[i] > +largest)
                // If the current value is larger, update the largest value
                largest = +categ.values[i]
    })

    return largest
}

/**
 * Find the smallest value among the provided data points.
 *
 * @param {Array} data - An array of columns with associated values.
 * @returns {number} - The smallest value found in the data.
 */
function getSmallest(data) {
    // Initialize smallest with the first value of the first category
    let smallest = +data[0].values[0]

    // Iterate through each category(column)
    data.forEach(categ => {
        // Iterate through the values of each category
        for (let i = 0; i < categ.values.length; i++)
            // Convert the value to a number and compare with the current smallest
            if (+categ.values[i] < +smallest)
                // If the current value is smaller, update the smallest value
                smallest = +categ.values[i]
    })

    return smallest
}

/**
 * Lighten or darken a hex color.
 * 
 * @param {string} color - The hex color code.
 * @param {number} amount - The amount to lighten or darken the color by.
 * @returns {string} The adjusted hex color.
 */
function adjustColor(color, amount) {
    return '#' + color.replace(/^#/, '').replace(/../g, color => ('0' + Math.min(255, Math.max(0, parseInt(color, 16) + amount)).toString(16)).substr(-2))
}

/**
 * Class representing a generic chart
 */
class Chart {
    /**
     * Creates a new instance of the Chart class
     *
     * @param {HTMLCanvasElement} canvas - The canvas element to draw the chart on
     * @param {Array<Object>} data - The data to visualize on the chart
     * @param {Object} settings - The settings for the chart
     * @param {ZoomManager} zoom - The zoom manager for the chart.
     */
    constructor(canvas, data, settings, zoom) {
        this.data = data // The data to visualize on the chart
        this.settings = settings // The settings for the chart
        this.canvas = canvas // The canvas element to draw the chart on
        this.zoom = zoom // The zoom manager for the chart.
        this.ctx = canvas.getContext("2d") // The 2D drawing context for the canvas

        // Calculate the largest and smallest values from the data
        this.largest = getLargest(data) // The largest value in the data
        this.smallest = getSmallest(data) // The smallest value in the data

        // Find the largest length of values from all categories
        this.dataLen = Math.max( // get max index
            ...data.map(category => // from each category
                Object.keys(category.values)[Object.keys(category.values).length - 1] // gets last(largest) key
            )) + 1 // indexes start at zero, so the length is +1

        // Update the bounds of the chart based on the canvas size and margins
        this.updateBounds()
        // Initialize an empty array to store chart objects (shapes)
        this.objects = []
    }

    /**
     * Update the bounds based on the canvas size and margin settings.
     */
    updateBounds() {
        // Calculate the bounds using the canvas and margin settings
        this.bounds = this.getBounds(this.canvas, this.settings.margin)

        this.zoomBounds = this.getZoomBounds()

        // Calculate the scale and extreme values based on the largest and smallest data points
        this.scale = this.zoomBounds.height
            - (this.largest >= 0 ? (this.zoomBounds.bottom - this.zoomBounds.xAxis) : 0)
        this.extreme = this.largest <= 0 ? Math.abs(this.smallest) : Math.abs(this.largest)
    }

    /**
     * Calculate the bounds of the graph area based on the canvas size and margin.
     * @param {HTMLCanvasElement} canvas - The canvas element.
     * @param {number} graphMargin - The margin around the graph area.
     * @returns {Object} - The calculated bounds.
     */
    getBounds(canvas, graphMargin) {
        // Check if settings are valid
        if (isNaN(graphMargin))
            throw new Error("Invalid margin: " + graphMargin)

        let result = {
            top: graphMargin,
            bottom: canvas.height - graphMargin,
            left: graphMargin,
            right: canvas.width - graphMargin,
            height: canvas.height - 2 * graphMargin,
            width: canvas.width - 2 * graphMargin,
        }

        // Calculate the xAxis position based on the smallest and largest data points
        if (this.smallest >= 0)
            // If the smallest data point is non-negative, set the xAxis to the bottom border
            result.xAxis = result.bottom
        else if (this.largest <= 0)
            // If the largest data point is non-positive, set the xAxis to the top margin
            result.xAxis = result.top
        else
            // If the smallest and largest data points have different signs,
            // calculate the position of the xAxis to ensure proportional distribution
            // between the two points on the graph area.
            result.xAxis = result.bottom
                - result.height / ((Math.abs(this.largest)) + Math.abs(this.smallest)) * Math.abs(this.smallest)

        return result
    }

    /**
     * Retrieves the zoomed bounds of the chart.
     * @returns {Object} An object containing zoomed bounds information.
     */
    getZoomBounds() {
        let result = {}

        // Get screen coordinates of the top-left corner of the bounds
        let pos = this.zoom.worldToScreen(this.bounds.left, this.bounds.top)
        result.left = pos.x
        result.top = pos.y

        // Get screen coordinates of the bottom-right corner of the bounds
        pos = this.zoom.worldToScreen(this.bounds.right, this.bounds.bottom)
        result.right = pos.x
        result.bottom = pos.y

        // Prevent the possiblity of leaving the chart bounds
        if (result.left > this.bounds.left)
            result.left = this.bounds.left
        if (result.right < this.bounds.right)
            result.right = this.bounds.right
        if (result.top > this.bounds.top)
            result.top = this.bounds.top
        if (result.bottom < this.bounds.bottom)
            result.bottom = this.bounds.bottom

        // Calculate width and height of the zoomed bounds
        result.width = result.right - result.left
        result.height = result.bottom - result.top

        // Calculate the xAxis position based on the smallest and largest data points
        if (this.smallest >= 0)
            // If the smallest data point is non-negative, set the xAxis to the bottom border
            result.xAxis = result.bottom
        else if (this.largest <= 0)
            // If the largest data point is non-positive, set the xAxis to the top margin
            result.xAxis = result.top
        else
            // If the smallest and largest data points have different signs,
            // calculate the position of the xAxis to ensure proportional distribution
            // between the two points on the graph area.
            result.xAxis = result.bottom
                - result.height / ((Math.abs(this.largest)) + Math.abs(this.smallest)) * Math.abs(this.smallest)

        return result
    }

    /**
     * Checks if a given position is within the bounds.
     *
     * @param {Object} pos - The position to check with x and y coordinates.
     * @returns {boolean} Returns true if the position is within the bounds, false otherwise.
     */
    isInBounds(pos) {
        return !(
            pos.x < this.bounds.left ||
            pos.x > this.bounds.right ||
            pos.y < this.bounds.top ||
            pos.y > this.bounds.bottom)
    }

    /**
     * Draw the title on the canvas.
     */
    drawTitle() {
        // Check if settings are valid
        if (typeof this.settings.title !== "string" && typeof this.settings.title !== "number")
            throw new Error("Invalid title. Expected number or string, received: " + typeof this.settings.titleFont)

        // Calculate the x and y coordinates for the title
        // place in in the middle of the chart
        let x = this.canvas.width / 2
        let y = 25

        // Set the font style and alignment
        this.ctx.font = this.settings.titleFont.size + "px " + this.settings.titleFont.font
        this.ctx.fillStyle = "black"
        this.ctx.textAlign = "center"

        // Draw the title text on the canvas
        this.ctx.fillText(this.settings.title, x, y)
    }

    /**
     * Update the legend based on the displayLegend flag and provided legend element.
     * @param {boolean} displayLegend - Whether to display the legend.
     * @param {HTMLElement} legend - The HTML element to display the legend.
     */
    updateLegend(displayLegend, legend, chartLoader = null) {
        // Check if chart legend is enabled
        if (displayLegend) {
            // Generate HTML for each category/column
            this.data.forEach((categ, index) => {
                // Create a new div element for each category
                let element = document.createElement("div")

                // Set attribute to store column ID
                //element.setAttribute("col-id", index)

                // Add event listener to handle mouse down event
                element.addEventListener("mousemove", e => {
                    // Retrieve the column ID from the clicked element
                    //let colId = e.target.getAttribute("col-id")

                    // Filter objects based on the column ID
                    let objects = chartLoader.chart.objects.filter(object => object.colId == index)
                    // Add selected shape
                    if (chartLoader.selectedShapeIndex !== null)
                        objects.push(this.objects[chartLoader.selectedShapeIndex])

                    // Set effect canvas opacity and draw effect
                    chartLoader.effectCanvas.style.opacity = 1
                    chartLoader.chart.drawEffect(chartLoader.effectCanvas.getContext("2d"), objects)

                    e.stopImmediatePropagation()
                })

                // Set inner HTML for the legend element
                element.innerHTML = "<span style='display:inline-block;width:20px;background-color:" + categ.color + ";'>&nbsp;</span> " + categ.col_name
                // Append the element to the legend container
                legend.appendChild(element)
            })

            // Update the legend HTML and display it
            //legend.innerHTML = legendHTML
            legend.style.display = "block"
        } else {
            // Hide the legend
            legend.style.display = "none"
        }
    }

    /**
     * Resize the canvas to match its container's size.
     * This function clears the canvas, updates its dimensions, and updates bounds.
     */
    resizeCanvas() {
        // Set the canvas width to match the client width of the container
        this.canvas.width = this.canvas.clientWidth
        // Set the canvas height to match the client height of the container
        this.canvas.height = this.canvas.clientHeight

        // Remove old content
        this.clear()
        // Update the charts bounds after resizing
        this.updateBounds()
    }

    /**
     * Clear the canvas and remove all stored objects.
     */
    clear() {
        // Set the clip region to the whole canvas
        this.ctx.restore()
        this.ctx.save()

        // Check if a custom background color is set
        if (this.settings.backgroundColor == null) {
            // Clear the canvas using the default clear method
            this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
        } else {
            // Set the canvas background color to the custom color
            this.ctx.fillStyle = this.settings.backgroundColor
            this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
        }

        // Reset the global index for Shapes
        Shape.globalIndex = 0
        // Clear the objects array
        this.objects = []
    }

    /**
     * Draw a detection map. Draws all shapes on new canvas and endcodes indexes into colors.
     *
     * @param {CanvasRenderingContext2D} ctx - The 2D drawing context for the canvas.
     */
    drawDetectionMap(ctx) {
        // Move the drawing point to (0.5, 0.5) to avoid anti-aliasing issues
        ctx.moveTo(0.5, 0.5)
        // Set the line width to make the map bigger than visible shapes
        ctx.lineWidth = 50

        // Clear the entire canvas to prepare for redrawing
        ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
        ctx.fill()

        this.objects.forEach(object => {
            // Only draw objects on screen
            if (this.isInBounds(object.getCenter())) {
                // Encode shape index into color
                let color = "#" + object.index.toString(16).padStart(6, '0')
                ctx.fillStyle = color
                ctx.strokeStyle = color

                // Draw the shape
                object.draw(ctx)
                //ctx.stroke()
            }
        })
    }

    /**
     * Draw effects for given shape. Lightens the color and creates shadow border
     *
     * @param {CanvasRenderingContext2D} ctx - The 2D drawing context for the canvas.
     * @param {Array} objects - An array of shapes on the canvas.
     */
    drawEffect(ctx, objects) {
        // Clear the entire canvas to prepare for redrawing
        ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
        ctx.fill()

        ctx.shadowBlur = 15
        ctx.shadowColor = 'rgba(0,0,0,1)'
        objects.forEach(object => {
            // Get the color of the object based on its column ID
            let color = this.data[object.colId].color
            // Adjust the color to make it lighter by 20 units
            let lighterColor = adjustColor(color, 20)

            ctx.fillStyle = lighterColor
            ctx.strokeStyle = lighterColor
            
            // Draw the object
            object.draw(ctx)
            // Stroke to draw the border
            ctx.stroke()
        })
        ctx.shadowColor = 'rgba(0,0,0,0)'
    }

    /**
     * Draws the axis on the chart
     *
     * @param {boolean} [displayAxisValues=this.settings.displayAxisValues] - If axis values should be displayed
     */
    drawAxis(displayAxisValues = this.settings.displayAxisValues) {
        // Set the font for the axis labels
        this.ctx.font = this.settings.labelFont.size + "px " + this.settings.labelFont.font

        // Ensure yStep is positive and defined
        if (this.settings.yStep <= 0 || !this.settings.yStep)
            this.settings.yStep = 1

        // Draw ticks and labels for the Y-axis
        this.drawYAxisTicks(displayAxisValues)

        // Draw ticks and labels for the X-axis
        this.drawXAxisTicks(displayAxisValues)

        // Draw lines for both X and Y axis
        this.drawAxisLines()

        // Draw labels for both X and Y axis
        this.drawAxisLabels()

        // Draw the title if enabled
        if (this.settings.displayTitle)
            this.drawTitle()

        // Set the clip region to the inside bounds
        this.setClipRegion(this.bounds.left, this.bounds.top, this.bounds.width, this.bounds.height)
    }

    /**
     * Sets the clipping region for the canvas context.
     * @param {number} x - The x-coordinate of the top-left corner of the clipping region.
     * @param {number} y - The y-coordinate of the top-left corner of the clipping region.
     * @param {number} w - The width of the clipping region.
     * @param {number} h - The height of the clipping region.
     */
    setClipRegion(x, y, w, h) {
        this.ctx.beginPath()
        // Create a rectangular path for the clipping region
        this.ctx.rect(x, y, w, h)
        // Clip the canvas context to the specified region
        this.ctx.clip()
        this.ctx.closePath()
    }

    /**
     * Draws the ticks and labels on the Y-axis.
     */
    drawYAxisTicks() {
        this.ctx.beginPath()

        // Set stroke style for the ticks
        this.ctx.strokeStyle = "#BBB"
        this.ctx.lineWidth = 1
        this.ctx.fillStyle = "black"
        this.ctx.textAlign = "end"

        // Loop through the Y-axis values to draw ticks and labels
        for (let i = (this.smallest < 0) ? this.smallest : 0; i <= (this.largest >= 0 ? this.largest : 0); i += parseFloat(this.settings.yStep)) {

            // Calculate the Y-position for the tick
            let y = this.zoomBounds.xAxis - i * this.scale / this.extreme

            // Skip text and lines if out of bounds
            if (y < this.bounds.top || y > this.bounds.bottom)
                continue

            // Draw support lines if enabled
            if (this.settings.displaySupportLines) {
                this.ctx.moveTo(this.bounds.left, y)
                this.ctx.lineTo(this.bounds.right, y)
            }

            // Set text style and alignment for the Y-axis values
            if (this.settings.displayAxisValues)
                this.ctx.fillText(i, this.bounds.left - 3, y)

            // Draw the tick
            this.ctx.stroke()
        }
    }

    /**
     * Draws the ticks and labels on the X-axis
     *
     * @param {boolean} [displayAxisValues=true] - Whether to display axis values
     */
    drawXAxisTicks(displayAxisValues = true) {
        // Check if axis values should be displayed
        if (displayAxisValues) {
            // Set text style and alignment for the X-axis values
            this.ctx.fillStyle = "black"
            this.ctx.textAlign = "center"

            // Loop through the data to draw X-axis ticks and labels
            for (let i = 0; i < this.dataLen; i++) {
                // Calculate the X-position for the tick
                let x = this.zoomBounds.left + this.zoomBounds.width / (this.dataLen - 1) * i
                let y = this.bounds.bottom + 18

                // Skip text if out of bounds
                if (x < this.bounds.left || x > this.bounds.right)
                    continue

                // Set the text for the X-axis label
                let text = (i + 1).toString()

                // Draw the X-axis label
                this.ctx.fillText(text, x, y)
            }
            this.ctx.closePath()
        }
    }

    /**
     * Draws the lines for both X and Y axis
     */
    drawAxisLines() {
        // Set the stroke style and line width for the axis lines
        this.ctx.strokeStyle = "black"
        this.ctx.lineWidth = "2px"

        // Begin the path for drawing the axis lines
        this.ctx.beginPath()
        // Draw the vertical Y-axis line
        this.ctx.moveTo(this.bounds.left, this.bounds.top)
        this.ctx.lineTo(this.bounds.left, this.bounds.bottom)

        // Draw the horizontal X-axis line
        let y = this.zoomBounds.xAxis
        if (y < this.bounds.top)
            y = this.bounds.top
        if (y > this.bounds.bottom)
            y = this.bounds.bottom

        this.ctx.moveTo(this.bounds.left, y)
        this.ctx.lineTo(this.bounds.right, y)

        this.ctx.stroke()
    }

    /**
     * Draws the labels for both X and Y axis
     */
    drawAxisLabels() {
        // Begin the path for drawing the axis labels        
        this.ctx.beginPath()

        // Set font and text style for the X-axis label
        this.ctx.font = "20px Arial"
        this.ctx.fillStyle = "black"
        this.ctx.textAlign = "center"

        // Draw the X-axis label at the center of the axis
        this.ctx.fillText(
            this.settings.xLabel, // Text to display (X-axis label)
            this.bounds.width / 2 + this.bounds.left,    // X coordinate
            this.bounds.height + 2 * this.bounds.top - 5 // Y coordinate
        )


        // Save the current context before rotating for the Y-axis label
        this.ctx.save()
        // Rotate the context to draw the Y-axis label vertically
        this.ctx.rotate(-Math.PI / 2)
        // Recenter on the rotated axis
        this.ctx.textAlign = "center"

        // Draw the Y-axis label at the center of the axis
        this.ctx.fillText(
            this.settings.yLabel,                         // Text to display (Y-axis label)
            -(this.bounds.left + this.bounds.height / 2), // Y coordinate
            18                                            // X coordinate
        )

        // Restore the context to its previous state
        this.ctx.restore()

        this.ctx.stroke()
    }
}
