/*!
 * rspamd-D3Pie 1.1.2 (https://github.com/moisseev/rspamd-D3Pie)
 * Copyright (c) 2022, Alexander Moisseev, BSD 2-Clause
 */

/* exported D3Pie */
function D3Pie (id, options) {
    "use strict";

    // Validate ID to prevent CSS selector issues
    if (typeof id !== "string" || id.length === 0) throw new Error("D3Pie: id must be a non-empty string");
    // Only allow: letters, underscores, hyphens at start; then any word chars or hyphens
    if (!(/^[a-zA-Z_-][\w-]*$/u).test(id)) {
        throw new Error("D3Pie: id must start with a letter, underscore, or hyphen, and contain only " +
            "alphanumeric characters, hyphens (-), and underscores (_). Got: \"" + id + "\"");
    }

    const opts = $.extend(true, {
        canvasPadding: 5,
        cornerRadius: 3,
        duration: 1250,
        gradient: {
            enabled: true,
            percentage: 100
        },
        labels: {
            inner: {
                hideWhenLessThanPercentage: 4,
                offset: 0.15
            },
            outer: {
                collideHeight: 13,
                format: "label",
                pieDistance: 30
            }
        },
        padAngle: 0.01,
        pieCenterOffset: {
            x: 0,
            y: 0
        },
        size: {
            canvasHeight: 400,
            canvasWidth: 600,
            pieInnerRadius: "20%",
            pieOuterRadius: "85%"
        },
        title: "",
        total: {
            enabled: false
        }
    }, options);

    // Validate options to prevent calculation errors and silent failures
    const errors = [];

    // Helper function to get nested option value by path
    function getOptionValue (path) {
        const keys = path.split(".");
        let value = opts;
        for (const key of keys) {
            value = value[key];
        }
        return value;
    }

    // Validate numeric options with min/max constraints
    function validate (path, constraints) {
        const value = getOptionValue(path);
        const num = parseFloat(value);
        if (!isFinite(num)) {
            errors.push(path + " must be a finite number, got: " + value);
            return;
        }
        if (typeof constraints.min !== "undefined" && num < constraints.min) {
            errors.push(path + " must be >= " + constraints.min + ", got: " + num);
            return;
        }
        if (typeof constraints.max !== "undefined" && num > constraints.max) {
            errors.push(path + " must be <= " + constraints.max + ", got: " + num);
        }
    }

    validate("canvasPadding", {min: 0});
    validate("cornerRadius", {min: 0});
    validate("duration", {min: 0});
    validate("gradient.percentage", {min: 0, max: 100});
    validate("labels.inner.hideWhenLessThanPercentage", {min: 0, max: 100});
    validate("labels.inner.offset", {min: -1, max: 1});
    validate("labels.outer.collideHeight", {min: 1});
    validate("labels.outer.pieDistance", {min: 0});
    validate("padAngle", {min: 0, max: 2 * Math.PI});
    validate("pieCenterOffset.x", {});
    validate("pieCenterOffset.y", {});
    validate("size.canvasHeight", {min: 50});
    validate("size.canvasWidth", {min: 50});

    // Validate radius options (can be number or percentage string)
    function validateRadius (path) {
        const value = getOptionValue(path);

        // Allow number or percentage string
        if (typeof value === "number") {
            if (!isFinite(value) || value < 0) {
                errors.push(path + " must be a non-negative number, got: " + value);
            }
        } else if (typeof value === "string") {
            const match = (/^(?<pct>\d+)%$/u).exec(value);
            if (!match) {
                errors.push(path + " must be a number or percentage string (e.g. \"85%\"), got: " + value);
            } else {
                const percentage = parseInt(match.groups.pct, 10);
                if (percentage > 99) {
                    errors.push(path + " percentage must be 0-99, got: " + percentage + "%");
                }
            }
        } else {
            errors.push(path + " must be a number or percentage string, got: " + typeof value);
        }
    }

    validateRadius("size.pieOuterRadius");
    validateRadius("size.pieInnerRadius");

    // Validate enum options
    function validateEnum (path, allowedValues) {
        const value = getOptionValue(path);

        if (!allowedValues.includes(value)) {
            const allowedStr = allowedValues.map((v) => "\"" + v + "\"").join(", ");
            errors.push(path + " must be one of [" + allowedStr + "], got: \"" + value + "\"");
        }
    }

    validateEnum("labels.outer.format", ["none", "label"]);

    if (errors.length > 0) throw new Error("D3Pie configuration errors:\n  - " + errors.join("\n  - "));

    this.destroy = function () {
        // Remove all event listeners before removing elements
        d3.select("#" + id).selectAll("*")
            .on("click", null)
            .on("mousemove", null)
            .on("mouseover", null)
            .on("mouseout", null);

        // Remove SVG and tooltip elements
        d3.selectAll("#" + id + " svg, #" + id + "-tooltip").remove();
    };
    this.destroy();

    const svg = d3.select("#" + id).append("svg")
        .attr("class", "d3pie")
        .attr("width", opts.size.canvasWidth)
        .attr("height", opts.size.canvasHeight);

    let titleHeight = 0;
    if (opts.title !== "") {
        const title = svg.append("svg:text")
            .attr("class", "chart-title")
            .attr("x", (opts.size.canvasWidth / 2));
        title.append("tspan")
            .text(opts.title + " ");
        titleHeight = title.node().getBBox().height;
        title.attr("y", titleHeight + opts.canvasPadding);
    }

    const g = svg.append("g")
        .attr("transform", "translate(" + ((opts.size.canvasWidth / 2) + opts.pieCenterOffset.x) + "," +
          ((opts.size.canvasHeight / 2) + (titleHeight / 2) + opts.pieCenterOffset.y + ")"));

    const currentData = {};
    const outerLabel = {};

    const {outerRadius, innerRadius} = (function () {
        function pieRadius (r, whole) {
            if (!(/%/u).test(r)) return parseInt(r, 10);
            const decimal = Math.max(0, Math.min(99, parseInt(r.replace(/[\D]/u, ""), 10))) / 100;
            return Math.floor(whole * decimal);
        }

        const width = opts.size.canvasWidth - (2 * opts.canvasPadding);
        const height = opts.size.canvasHeight - (2 * opts.canvasPadding) - titleHeight;
        let overallRadius = Math.min(width, height) / 2;
        if (opts.labels.outer.format !== "none") {
            const pieDistance = parseInt(opts.labels.outer.pieDistance, 10);
            if (overallRadius > pieDistance) overallRadius -= pieDistance;
        }
        const oR = pieRadius(opts.size.pieOuterRadius, overallRadius);
        const iR = pieRadius(opts.size.pieInnerRadius, oR);
        return {outerRadius: oR, innerRadius: iR};
    }());

    const labelRadius = outerRadius + opts.labels.outer.pieDistance;

    const lineGenerator = d3.line()
        .curve(d3.curveCatmullRomOpen);

    const tooltip = d3.select("body").append("div")
        .attr("id", id + "-tooltip")
        .attr("class", "d3pie-tooltip");
    const tooltipText = tooltip
        .append("span")
        .attr("id", id + "-tooltip-text");

    function attachListeners (selection) {
        selection
            .on("mouseover", function (_, d) {
                const tot = tooltip.datum().total;
                const percentage = (tot) ? Math.round(100 * d.data.value / tot) : NaN;
                if (d.data.value) {
                    tooltip
                        .transition().duration(300)
                        .style("opacity", 1);
                    tooltipText
                        .text(d.data.label + ((tot) ? ": " + d.data.value + " (" + percentage + "%)" : ""));
                } else {
                    tooltip.transition().duration(300).style("opacity", 0);
                }
                // eslint-disable-next-line no-invalid-this
                tooltip.each(function (datum) { datum.height = this.getBoundingClientRect().height; });
            })
            .on("mouseout", function () {
                tooltip.transition().duration(300).style("opacity", 0);
            })
            .on("mousemove", (event) => {
                const {pageX, pageY} = event;
                tooltip
                    .style("left", (pageX) + "px")
                    .style("top", function (d) { return (pageY - d.height - 2) + "px"; });
            });
    }

    // Slices render in this group (created first so totalG renders on top)
    const slicesGroup = g.append("g");

    const totalG = g.append("g");
    attachListeners(totalG);
    totalG.append("circle")
        .attr("r", innerRadius)
        .style("opacity", 0);
    if (opts.total.enabled) {
        const totalText = totalG.append("text")
            .attr("class", "total-text");
        totalText.append("tspan")
            .attr("class", "total-value")
            .style("font-size", (0.6 * innerRadius) + "px");
        totalText.append("tspan")
            .attr("x", "0")
            .attr("dy", 0.5 * innerRadius)
            .text((typeof opts.total.label !== "undefined") ? opts.total.label : "Total");
    }

    const defs = svg.append("defs");

    // Mask to prevent color bleeding artifact in donut hole
    // When padAngle is large and slice angle > π, the inner arc wraps > 180° and renders through center.
    // This is D3's arc geometry behavior and cannot be fixed without changing the arc itself.
    // Reproduces with: padAngle ≥ 1.0, slice percentage > 50%
    // Note: With extreme padAngle values, artifacts may still be visible through gaps between other slices
    if (innerRadius > 0) {
        const mask = defs.append("mask")
            .attr("id", id + "-donut-mask");
        // White area = visible, black area = hidden
        // Mask coordinates are relative to the masked element (slicesGroup is at 0,0 in transformed g)
        mask.append("rect")
            .attr("x", -opts.size.canvasWidth)
            .attr("y", -opts.size.canvasHeight)
            .attr("width", opts.size.canvasWidth * 2)
            .attr("height", opts.size.canvasHeight * 2)
            .attr("fill", "white");
        // Black circle in center = hide slices there
        mask.append("circle")
            .attr("r", innerRadius)
            .attr("fill", "black");

        // Apply mask only to slicesGroup, not to totalG (so text remains visible)
        slicesGroup.attr("mask", "url(#" + id + "-donut-mask)");
    }

    this.data = function (arg) {
        let data = $.extend(true, [], arg);

        // Validate that labels are unique and non-empty (required for D3 keyed data binding)
        const labels = new Set();
        for (let i = 0; i < data.length; i += 1) {
            const item = data[i];
            if (!item || typeof item.label !== "string" || item.label === "") {
                throw new Error("D3Pie: data[" + i + "] must have a non-empty string 'label' property");
            }
            if (labels.has(item.label)) {
                throw new Error("D3Pie: duplicate label \"" + item.label + "\" found. All labels must be unique.");
            }
            labels.add(item.label);
        }

        const nodes = [];

        const total = data.reduce(function (a, b) {
            return a + (b.value || 0);
        }, 0);
        tooltip.datum({total});
        totalG.datum({
            data: {
                label: (typeof opts.total.label !== "undefined") ? opts.total.label : "Total",
                value: total
            }
        });
        if (opts.total.enabled) totalG.select(".total-value").text(d3.format(".3~s")(total));

        // Add placeholder path for empty pie chart
        data.unshift({
            label: "undefined",
            color: opts.gradient.enabled ? "steelblue" : "#ecf1f5",
            value: (total === 0) ? 1 : 0
        });

        const colorScale = d3.scaleOrdinal(d3.schemeSet1);
        function pathColor (d, i) {
            return (typeof d !== "undefined" &&
                    typeof d.color !== "undefined")
                ? d.color
                : colorScale(i);
        }

        function midpoint (args, innerR, outerR = innerR) {
            return d3.arc().innerRadius(innerR).outerRadius(outerR).centroid(args);
        }

        function interpolate (d) { return d3.interpolate(currentData[d.data.label], d); }

        function arcTweenSlice (d) {
            return function (t) {
                return d3.arc().padAngle(opts.padAngle).cornerRadius(opts.cornerRadius)
                    .innerRadius(innerRadius).outerRadius(outerRadius)(interpolate(d)(t));
            };
        }

        function arcTweenInnerlabel (d) {
            return function (t) {
                return "translate(" + midpoint(
                    interpolate(d)(t),
                    innerRadius * (1 - opts.labels.inner.offset),
                    outerRadius * (1 + opts.labels.inner.offset)
                ) + ")";
            };
        }

        function limit (y) {
            // Avoid 0 and pi positions to prevent changing half-plane
            const boundary = labelRadius - 0.1;
            return Math.max(-boundary, Math.min(boundary, y));
        }

        function arcTweenOuterlabel (d, i) {
            function newAngle () {
                // Node Y may be slightly outside of the circle as the bounding force is not a hard limit.
                const y = limit(nodes[i].y);
                // Calculate the actual X-position with an equation of a circle centered at the origin (0, 0).
                let ax = Math.sqrt(Math.pow((labelRadius), 2) - Math.pow(y, 2));
                if ((d.endAngle + d.startAngle) / 2 > Math.PI) ax *= -1;

                let angle = (Math.PI / 2) - Math.atan2(-y, ax);
                if (angle < 0) angle += 2 * Math.PI;

                return {startAngle: angle, endAngle: angle};
            }

            outerLabel[d.data.label].newAngle = newAngle();
            const interpolateOuterLabel = d3.interpolate(
                outerLabel[d.data.label].currentAngle,
                outerLabel[d.data.label].newAngle
            );

            return function (t) {
                const  position = midpoint(interpolateOuterLabel(t), labelRadius);
                const  [ax, y] = position;

                const linkDistance = 5;
                const label = (ax > 0)
                    ? {dx: linkDistance, textAnchor: "start"}
                    : {dx: -linkDistance, textAnchor: "end"};

                d3.select(slicesGroup.selectAll(".link").nodes()[i])
                    .datum([
                        midpoint(interpolate(d)(t), outerRadius),
                        midpoint(interpolate(d)(t), outerRadius + linkDistance),
                        [ax, y],
                        [ax + label.dx, y]
                    ])
                    .attr("d", lineGenerator);

                d3.select(slicesGroup.selectAll(".outer-label").nodes()[i])
                    .attr("dx", label.dx)
                    .style("text-anchor", label.textAnchor);

                return "translate(" + position + ")";
            };
        }

        const transition = d3.transition().duration(opts.duration);

        if (opts.gradient.enabled) {
            const gradient = defs.selectAll("radialGradient").data(data, function (d) { return d.label; });

            const gradientEnter = gradient.enter().append("radialGradient")
                .attr("gradientUnits", "userSpaceOnUse")
                .attr("cx", 0)
                .attr("cy", 0)
                .attr("r", "120%")
                .attr("id", function (d, i) { return id + "-grad" + i; });
            gradientEnter.append("stop")
                .attr("class", "grad-stop-0")
                .style("stop-color", function (d, i) { return pathColor(d, i); });
            gradientEnter.append("stop")
                .attr("class", "grad-stop-1")
                .attr("offset", opts.gradient.percentage + "%");

            defs.selectAll("radialGradient").select(".grad-stop-0")
                .transition(transition)
                .style("stop-color", function (d, i) { return pathColor(d, i); });
        }

        function key (d) { return d.data.label; }

        const pie = d3.pie()
            .sort(null)
            .value(function (d) { return d.value; });

        const sliceG = slicesGroup.selectAll(".slice-g").data(pie(data), key);

        const sliceGEnter = sliceG.enter().append("g")
            .attr("class", "slice-g");
        attachListeners(sliceGEnter);

        sliceGEnter
            .append("path")
            .attr("id", function (d, i) { return id + "-slice" + i; })
            .attr("class", "slice")
            .attr("fill", function (d, i) {
                return opts.gradient.enabled
                    ? "url(#" + id + "-grad" + i + ")"
                    : pathColor(d.data, i);
            });

        sliceG
            .exit()
            .each(function (d) {
                // Find by label instead of using exit selection index to avoid array corruption
                const dataItem = data.find(function (item) { return item.label === d.data.label; });
                // Set value to 0 to animate shrinking, preserving other properties (color, etc.)
                if (dataItem) dataItem.value = 0;
            });

        pie(data).forEach(function (d, i) {
            if (typeof currentData[d.data.label] === "undefined") {
                const a = (i) ? currentData[pie(data)[i - 1].data.label].endAngle : 0;
                currentData[d.data.label] = {startAngle: a, endAngle: a};
            }
        });

        slicesGroup.selectAll(".slice")
            .data(pie(data), key)
            .transition(transition)
            .attrTween("d", arcTweenSlice)
            .end()
            .then(function () {
                data = data.filter(function (d, i) { return (i === 0 || d.value); });

                defs.selectAll("radialGradient").data(data, function (d) { return d.label; }).exit().remove();
                slicesGroup.selectAll(".slice-g").data(pie(data), key).exit()
                    .each(function (d) {
                        delete currentData[d.data.label];
                        delete outerLabel[d.data.label];
                    })
                    .remove();

                for (const d of pie(data)) {
                    currentData[d.data.label] = d;
                    if (opts.labels.outer.format !== "none") {
                        outerLabel[d.data.label].currentAngle = outerLabel[d.data.label].newAngle;
                    }
                }

                // Ensure exactly one element has the 'first-slice' class:
                // remove from all, then set on the first .slice-g in DOM order.
                slicesGroup.selectAll(".slice-g").classed("first-slice", false);
                const firstSlice = slicesGroup.select(".slice-g");
                if (!firstSlice.empty()) firstSlice.classed("first-slice", true);
            })
            .catch((error) => {
                // Interrupted transitions reject with pie state object - ignore these, log real errors
                // Transition states have startAngle/endAngle properties from d3.pie() data
                if (error && typeof error === "object" && "startAngle" in error && "endAngle" in error) return;
                console.error("D3Pie error:", error);  // eslint-disable-line no-console
            });


        sliceGEnter.append("text")
            .attr("class", "inner-label")
            .attr("dy", ".35em");

        function opacityTweenInnerlabels (d) {
            if (!d.data.value) return function () { return 0; };
            return function (t) {
                const o = interpolate(d)(t);
                const percentage = 100 * (o.endAngle - o.startAngle) / (2 * Math.PI);
                return percentage < opts.labels.inner.hideWhenLessThanPercentage ? 0 : 1;
            };
        }

        slicesGroup.selectAll(".inner-label").data(pie(data), key)
            .text(function (d) {
                return (d.data.label === "undefined")
                    ? "undefined"
                    : Math.round(100 * d.data.value / total) + "%";
            })
            .transition(transition)
            .attrTween("opacity", opacityTweenInnerlabels)
            .attrTween("transform", arcTweenInnerlabel);


        if (opts.labels.outer.format !== "none") {
            pie(data).forEach(function (d, i) {
                if (typeof outerLabel[d.data.label] === "undefined") {
                    const a = (i) ? currentData[pie(data)[i - 1].data.label].endAngle : 0;
                    outerLabel[d.data.label] = {
                        currentAngle: {startAngle: a, endAngle: a},
                        newAngle: {startAngle: a, endAngle: a}
                    };
                }

                let fx = 0;
                const [x, y] = midpoint(d, labelRadius);
                if (d.data.value) {
                    fx = (x >= 0) ? opts.labels.outer.collideHeight : -opts.labels.outer.collideHeight;
                }
                nodes.push({fx, y});
            });
            d3.forceSimulation(nodes).alphaMin(0.5)
                .force("collide", d3.forceCollide(opts.labels.outer.collideHeight / 2))
                // Custom force to keep all nodes on a circle.
                .force("boundY", function () {
                    for (const node of nodes) {
                        // Constrain the Y-position of the node to keep it on a circle.
                        node.y = limit(node.y);
                    }
                })
                .tick(30);

            const outerLabelsGEnter = sliceGEnter
                .append("g")
                .attr("class", "outer-label-g");

            outerLabelsGEnter.append("text")
                .attr("class", "outer-label")
                .attr("dy", ".35em")
                .text(key);

            outerLabelsGEnter.append("path")
                .attr("class", "link");

            slicesGroup.selectAll(".outer-label-g").data(pie(data), key)
                .transition(transition)
                .style("opacity", function (d, i) { return (i && d.value) ? 1 : 0; })
                // eslint-disable-next-line no-invalid-this
                .each(function (d, i) { $(this).children(".link").attr("stroke", pathColor(d.data, i)); });

            slicesGroup.selectAll(".outer-label").data(pie(data), key)
                .transition(transition)
                .attrTween("transform", arcTweenOuterlabel);
        }
    };
}
