package com.foxx.androidcast.studio;

/*********************************************************************
 * SessionChartPane.java
 * Created at: Mon 18 May 2026 18:26:28 +0200
 * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
 * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
 * class SessionChartPane
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (3 commits, 444 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	b481c013163b99f872c4174d6b6ad9505f01dfeae596bbb601e448983be17d8a
**********************************************************************/
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

/** Multi-session line chart: zoom, grid, legend, hover marker + tooltip. */
public final class SessionChartPane extends StackPane {
    public enum Metric {
        BITRATE("Video bitrate (kbps)"),
        LOSS("Packet loss (%)"),
        ENCODE_FPS("Encode FPS");

        final String axisLabel;

        Metric(String axisLabel) {
            this.axisLabel = axisLabel;
        }
    }

    private static final Color[] SESSION_COLORS = {
            Color.web("#64B5F6"), Color.web("#FF8A65"), Color.web("#81C784"),
            Color.web("#FFD54F"), Color.web("#BA68C8"), Color.web("#4DD0E1")
    };

    private static final double PAD_LEFT = 64;
    private static final double PAD_RIGHT = 16;
    private static final double PAD_TOP = 16;
    private static final double PAD_BOTTOM = 40;
    private static final Color GRID_COLOR = Color.web("#3a3a3a");
    private static final Color AXIS_COLOR = Color.web("#aaaaaa");

    private final Canvas canvas = new Canvas(800, 480);
    private final List<SessionDocument> sessions = new ArrayList<>();
    private Metric metric = Metric.BITRATE;

    private double viewMinT;
    private double viewMaxT = 1;
    private double viewMinY;
    private double viewMaxY = 1;
    private boolean viewInitialized;

    private double hoverMouseX = -1;
    private double hoverMouseY = -1;
    private int hoverSessionIndex = -1;
    private SessionDocument.SamplePoint hoverPoint;
    private double hoverPlotX;
    private double hoverPlotY;

    public SessionChartPane() {
        getChildren().add(canvas);
        canvas.widthProperty().bind(widthProperty());
        canvas.heightProperty().bind(heightProperty());
        canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
        canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> clearHover());
        canvas.addEventHandler(ScrollEvent.SCROLL, this::onScroll);
    }

    public void render(List<SessionDocument> docs, Metric metric) {
        sessions.clear();
        if (docs != null) {
            sessions.addAll(docs);
        }
        this.metric = metric != null ? metric : Metric.BITRATE;
        resetView();
        draw();
    }

    private void resetView() {
        viewMinT = 0;
        viewMaxT = Math.max(1, dataMaxTimeMs());
        double[] y = dataYRange();
        viewMinY = y[0];
        viewMaxY = y[1];
        viewInitialized = sessions.stream().anyMatch(d -> d.samples.size() >= 2);
    }

    private long dataMaxTimeMs() {
        long max = 1;
        for (SessionDocument doc : sessions) {
            for (SessionDocument.SamplePoint p : doc.samples) {
                max = Math.max(max, p.tMs);
            }
            max = Math.max(max, doc.durationMs);
        }
        return max;
    }

    private double[] dataYRange() {
        double min = Double.POSITIVE_INFINITY;
        double max = Double.NEGATIVE_INFINITY;
        for (SessionDocument doc : sessions) {
            for (SessionDocument.SamplePoint p : doc.samples) {
                double v = metricValue(p);
                if (Double.isFinite(v)) {
                    min = Math.min(min, v);
                    max = Math.max(max, v);
                }
            }
        }
        if (!Double.isFinite(min)) {
            min = 0;
            max = 1;
        }
        if (max <= min) {
            max = min + 1;
        }
        double pad = (max - min) * 0.06;
        return new double[] {min - pad, max + pad};
    }

    private void onScroll(ScrollEvent e) {
        if (!viewInitialized) {
            return;
        }
        double plotW = plotWidth();
        double plotH = plotHeight();
        if (plotW <= 0 || plotH <= 0) {
            return;
        }
        double factor = e.getDeltaY() > 0 ? 0.85 : 1.0 / 0.85;
        double mx = e.getX();
        double my = e.getY();
        if (mx >= PAD_LEFT && mx <= PAD_LEFT + plotW && my >= PAD_TOP && my <= PAD_TOP + plotH) {
            double relX = (mx - PAD_LEFT) / plotW;
            double relY = 1.0 - (my - PAD_TOP) / plotH;
            double tAnchor = viewMinT + relX * (viewMaxT - viewMinT);
            double yAnchor = viewMinY + relY * (viewMaxY - viewMinY);
            zoomTime(tAnchor, factor);
            zoomY(yAnchor, factor);
        } else {
            zoomTime((viewMinT + viewMaxT) / 2, factor);
            zoomY((viewMinY + viewMaxY) / 2, factor);
        }
        e.consume();
        draw();
    }

    private void zoomTime(double anchor, double factor) {
        double span = viewMaxT - viewMinT;
        if (span <= 0) {
            return;
        }
        double newSpan = Math.max(50, span * factor);
        viewMinT = anchor - (anchor - viewMinT) * (newSpan / span);
        viewMaxT = viewMinT + newSpan;
    }

    private void zoomY(double anchor, double factor) {
        double span = viewMaxY - viewMinY;
        if (span <= 0) {
            return;
        }
        double newSpan = Math.max(1, span * factor);
        viewMinY = anchor - (anchor - viewMinY) * (newSpan / span);
        viewMaxY = viewMinY + newSpan;
    }

    private void onMouseMoved(MouseEvent e) {
        hoverMouseX = e.getX();
        hoverMouseY = e.getY();
        updateHoverHit();
        draw();
    }

    private void clearHover() {
        hoverMouseX = -1;
        hoverMouseY = -1;
        hoverSessionIndex = -1;
        hoverPoint = null;
        draw();
    }

    private void updateHoverHit() {
        hoverSessionIndex = -1;
        hoverPoint = null;
        double plotW = plotWidth();
        double plotH = plotHeight();
        if (plotW <= 0 || plotH <= 0 || sessions.isEmpty()) {
            return;
        }
        if (hoverMouseX < PAD_LEFT || hoverMouseX > PAD_LEFT + plotW
                || hoverMouseY < PAD_TOP || hoverMouseY > PAD_TOP + plotH) {
            return;
        }
        double bestDist = 24;
        for (int si = 0; si < sessions.size(); si++) {
            SessionDocument doc = sessions.get(si);
            for (SessionDocument.SamplePoint p : doc.samples) {
                if (p.tMs < viewMinT || p.tMs > viewMaxT) {
                    continue;
                }
                double v = metricValue(p);
                if (v < viewMinY || v > viewMaxY) {
                    continue;
                }
                double x = toScreenX(p.tMs);
                double y = toScreenY(v);
                double d = Math.hypot(hoverMouseX - x, hoverMouseY - y);
                if (d < bestDist) {
                    bestDist = d;
                    hoverSessionIndex = si;
                    hoverPoint = p;
                    hoverPlotX = x;
                    hoverPlotY = y;
                }
            }
        }
    }

    private double plotWidth() {
        return Math.max(0, canvas.getWidth() - PAD_LEFT - PAD_RIGHT);
    }

    private double plotHeight() {
        return Math.max(0, canvas.getHeight() - PAD_TOP - PAD_BOTTOM);
    }

    private double toScreenX(double tMs) {
        double plotW = plotWidth();
        double span = viewMaxT - viewMinT;
        if (span <= 0) {
            return PAD_LEFT;
        }
        return PAD_LEFT + ((tMs - viewMinT) / span) * plotW;
    }

    private double toScreenY(double v) {
        double plotH = plotHeight();
        double span = viewMaxY - viewMinY;
        if (span <= 0) {
            return PAD_TOP + plotH;
        }
        return PAD_TOP + plotH - ((v - viewMinY) / span) * plotH;
    }

    private double metricValue(SessionDocument.SamplePoint p) {
        switch (metric) {
            case LOSS:
                return p.lossRatio * 100.0;
            case ENCODE_FPS:
                return p.encodeFps;
            case BITRATE:
            default:
                return p.videoBitrateKbps;
        }
    }

    private void draw() {
        GraphicsContext g = canvas.getGraphicsContext2D();
        double w = canvas.getWidth();
        double h = canvas.getHeight();
        g.setFill(Color.web("#121212"));
        g.fillRect(0, 0, w, h);

        if (sessions.isEmpty()) {
            g.setFill(Color.LIGHTGRAY);
            g.setFont(Font.font(14));
            g.fillText("Open session_send_* / session_recv_* JSON (Grab session stats on device)", 24, 40);
            return;
        }

        double plotW = plotWidth();
        double plotH = plotHeight();
        if (plotW <= 1 || plotH <= 1) {
            return;
        }

        List<ChartTicks.Tick> xTicks = ChartTicks.timeMs(
                (long) viewMinT, (long) viewMaxT, 8);
        List<ChartTicks.Tick> yTicks = ChartTicks.linear(viewMinY, viewMaxY, 6);

        g.setStroke(GRID_COLOR);
        g.setLineWidth(1);
        for (ChartTicks.Tick t : xTicks) {
            double x = toScreenX(t.value);
            g.strokeLine(x, PAD_TOP, x, PAD_TOP + plotH);
        }
        for (ChartTicks.Tick t : yTicks) {
            double y = toScreenY(t.value);
            g.strokeLine(PAD_LEFT, y, PAD_LEFT + plotW, y);
        }

        g.setStroke(AXIS_COLOR);
        g.setLineWidth(1.5);
        g.strokeLine(PAD_LEFT, PAD_TOP + plotH, PAD_LEFT + plotW, PAD_TOP + plotH);
        g.strokeLine(PAD_LEFT, PAD_TOP, PAD_LEFT, PAD_TOP + plotH);

        g.setFill(AXIS_COLOR);
        g.setFont(Font.font(10));
        for (ChartTicks.Tick t : xTicks) {
            double x = toScreenX(t.value);
            g.fillText(t.label, x - 16, PAD_TOP + plotH + 14);
        }
        for (ChartTicks.Tick t : yTicks) {
            double y = toScreenY(t.value);
            g.fillText(t.label, 4, y + 4);
        }

        g.setFill(Color.web("#cccccc"));
        g.setFont(Font.font(11));
        g.fillText("Time", PAD_LEFT + plotW / 2 - 16, h - 4);
        g.save();
        g.translate(12, PAD_TOP + plotH / 2);
        g.rotate(-90);
        g.fillText(metric.axisLabel, 0, 0);
        g.restore();

        for (int si = 0; si < sessions.size(); si++) {
            SessionDocument doc = sessions.get(si);
            if (doc.samples.size() < 2) {
                continue;
            }
            drawSessionLine(g, doc, SESSION_COLORS[si % SESSION_COLORS.length]);
        }

        drawLegend(g, plotW);

        if (hoverPoint != null && hoverSessionIndex >= 0) {
            Color c = SESSION_COLORS[hoverSessionIndex % SESSION_COLORS.length];
            g.setFill(c);
            g.fillOval(hoverPlotX - 5, hoverPlotY - 5, 10, 10);
            g.setStroke(Color.WHITE);
            g.setLineWidth(1.5);
            g.strokeOval(hoverPlotX - 5, hoverPlotY - 5, 10, 10);
            drawHoverPopup(g, c);
        }
    }

    private void drawSessionLine(GraphicsContext g, SessionDocument doc, Color color) {
        g.setStroke(color);
        g.setLineWidth(2);
        g.beginPath();
        boolean first = true;
        for (SessionDocument.SamplePoint p : doc.samples) {
            if (p.tMs < viewMinT || p.tMs > viewMaxT) {
                continue;
            }
            double v = metricValue(p);
            if (v < viewMinY || v > viewMaxY) {
                continue;
            }
            double x = toScreenX(p.tMs);
            double y = toScreenY(v);
            if (first) {
                g.moveTo(x, y);
                first = false;
            } else {
                g.lineTo(x, y);
            }
        }
        g.stroke();
    }

    private void drawLegend(GraphicsContext g, double plotW) {
        double x0 = PAD_LEFT + plotW - 8;
        double y = PAD_TOP + 8;
        double rowH = 18;
        double boxW = 14;
        int rows = 0;
        for (SessionDocument doc : sessions) {
            if (!doc.samples.isEmpty()) {
                rows++;
            }
        }
        if (rows == 0) {
            return;
        }
        double legendW = 220;
        double legendH = rows * rowH + 12;
        x0 -= legendW;
        g.setFill(Color.rgb(0, 0, 0, 0.72));
        g.fillRoundRect(x0, y, legendW, legendH, 6, 6);
        g.setStroke(Color.web("#888888"));
        g.strokeRoundRect(x0, y, legendW, legendH, 6, 6);

        int row = 0;
        for (int si = 0; si < sessions.size(); si++) {
            SessionDocument doc = sessions.get(si);
            if (doc.samples.isEmpty()) {
                continue;
            }
            Color c = SESSION_COLORS[si % SESSION_COLORS.length];
            double ry = y + 8 + row * rowH;
            g.setFill(c);
            g.fillRoundRect(x0 + 8, ry, boxW, boxW - 2, 2, 2);
            g.setStroke(Color.web("#bbbbbb"));
            g.strokeRoundRect(x0 + 8, ry, boxW, boxW - 2, 2, 2);
            boolean bold = si == hoverSessionIndex;
            g.setFill(Color.WHITE);
            g.setFont(Font.font("System", bold ? FontWeight.BOLD : FontWeight.NORMAL, 11));
            String name = doc.path;
            if (name.length() > 26) {
                name = "…" + name.substring(name.length() - 25);
            }
            g.fillText(name, x0 + 8 + boxW + 6, ry + 11);
            row++;
        }
    }

    private void drawHoverPopup(GraphicsContext g, Color accent) {
        SessionDocument doc = sessions.get(hoverSessionIndex);
        String text = String.format(Locale.US,
                "t=%d ms  bitrate=%.0f kbps  loss=%.2f%%  fps=%.1f",
                hoverPoint.tMs,
                hoverPoint.videoBitrateKbps,
                hoverPoint.lossRatio * 100.0,
                hoverPoint.encodeFps);
        double tw = Math.min(plotWidth() - 20, g.getFont().getSize() * 0.55 * text.length() + 20);
        double th = 28;
        double px = hoverPlotX + 12;
        double py = hoverPlotY - th - 12;
        if (px + tw > PAD_LEFT + plotWidth()) {
            px = hoverPlotX - tw - 12;
        }
        if (py < PAD_TOP) {
            py = hoverPlotY + 12;
        }
        g.setFill(Color.rgb(0, 0, 0, 0.88));
        g.fillRoundRect(px, py, tw, th, 6, 6);
        g.setStroke(accent);
        g.setLineWidth(1);
        g.strokeRoundRect(px, py, tw, th, 6, 6);
        g.setFill(Color.WHITE);
        g.setFont(Font.font(11));
        g.fillText(text, px + 8, py + 18);
    }
}
