package com.foxx.androidcast.stats.ui;

/*********************************************************************
 * SessionLineChartView.java
 * Created at: Tue 19 May 2026 19:43:06 +0200
 * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
 * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
 * class SessionLineChartView
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 488 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	1bd2a4d05ae795d17ebc64081ad74db589b56866cb6798a9d6d1347698d4e479
**********************************************************************/
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Typeface;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;

import androidx.annotation.Nullable;

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

/**
 * Multi-session line chart with zoom, grid, legend, and hover marker + tooltip.
 */
public class SessionLineChartView extends View {
    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 int[] SESSION_COLORS = {
            0xFF64B5F6, 0xFFFF8A65, 0xFF81C784, 0xFFFFD54F, 0xFFBA68C8, 0xFF4DD0E1
    };

    private static final float PAD_LEFT = 72f;
    private static final float PAD_RIGHT = 16f;
    private static final float PAD_TOP = 16f;
    private static final float PAD_BOTTOM = 48f;

    private final Paint gridPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint axisPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint legendBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint legendBorderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint swatchPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint markerFill = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint markerStroke = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint tooltipBg = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Paint tooltipFg = new Paint(Paint.ANTI_ALIAS_FLAG);

    private final List<SessionRecord> sessions = new ArrayList<>();
    private Metric metric = Metric.BITRATE;
    private final ScaleGestureDetector scaleDetector;

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

    private float hoverX = -1;
    private float hoverY = -1;
    private int hoverSessionIndex = -1;
    private SessionRecord.SamplePoint hoverPoint;
    private float hoverPlotX;
    private float hoverPlotY;

    public SessionLineChartView(Context context) {
        super(context);
        scaleDetector = new ScaleGestureDetector(context, new ScaleListener());
        initPaints();
    }

    public SessionLineChartView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        scaleDetector = new ScaleGestureDetector(context, new ScaleListener());
        initPaints();
    }

    private void initPaints() {
        gridPaint.setColor(0xFF3A3A3A);
        gridPaint.setStrokeWidth(1f);
        axisPaint.setColor(0xFFAAAAAA);
        axisPaint.setStrokeWidth(2f);
        linePaint.setStyle(Paint.Style.STROKE);
        linePaint.setStrokeWidth(3f);
        textPaint.setColor(0xFFBBBBBB);
        textPaint.setTextSize(22f);
        legendBgPaint.setColor(0xB8000000);
        legendBorderPaint.setColor(0xFF888888);
        legendBorderPaint.setStyle(Paint.Style.STROKE);
        legendBorderPaint.setStrokeWidth(1f);
        swatchPaint.setStyle(Paint.Style.FILL);
        markerFill.setStyle(Paint.Style.FILL);
        markerStroke.setStyle(Paint.Style.STROKE);
        markerStroke.setColor(0xFFFFFFFF);
        markerStroke.setStrokeWidth(2f);
        tooltipBg.setColor(0xE0000000);
        tooltipFg.setColor(0xFFFFFFFF);
        tooltipFg.setTextSize(24f);
    }

    public void setSessions(@Nullable List<SessionRecord> records, Metric metric) {
        sessions.clear();
        if (records != null) {
            sessions.addAll(records);
        }
        this.metric = metric != null ? metric : Metric.BITRATE;
        resetView();
        clearHover();
        invalidate();
    }

    private void resetView() {
        viewMinT = 0;
        viewMaxT = Math.max(1, dataMaxTimeMs());
        double[] y = dataYRange();
        viewMinY = y[0];
        viewMaxY = y[1];
    }

    private long dataMaxTimeMs() {
        long max = 1;
        for (SessionRecord doc : sessions) {
            for (SessionRecord.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 (SessionRecord doc : sessions) {
            for (SessionRecord.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)) {
            return new double[] {0, 1};
        }
        if (max <= min) {
            max = min + 1;
        }
        double pad = (max - min) * 0.06;
        return new double[] {min - pad, max + pad};
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        scaleDetector.onTouchEvent(event);
        int action = event.getActionMasked();
        if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE) {
            hoverX = event.getX();
            hoverY = event.getY();
            updateHoverHit();
            invalidate();
            return true;
        }
        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
            clearHover();
            return true;
        }
        return super.onTouchEvent(event);
    }

    @Override
    public boolean onGenericMotionEvent(MotionEvent event) {
        if ((event.getSource() & android.view.InputDevice.SOURCE_CLASS_POINTER) != 0
                && event.getAction() == MotionEvent.ACTION_SCROLL) {
            float factor = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0 ? 0.85f : 1.18f;
            zoomAt(event.getX(), event.getY(), factor);
            invalidate();
            return true;
        }
        return super.onGenericMotionEvent(event);
    }

    private void zoomAt(float mx, float my, float factor) {
        float plotW = plotWidth();
        float plotH = plotHeight();
        if (plotW <= 0 || plotH <= 0) {
            return;
        }
        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;
            zoomTime(viewMinT + relX * (viewMaxT - viewMinT), factor);
            zoomY(viewMinY + relY * (viewMaxY - viewMinY), factor);
        } else {
            zoomTime((viewMinT + viewMaxT) / 2, factor);
            zoomY((viewMinY + viewMaxY) / 2, factor);
        }
    }

    private void zoomTime(double anchor, float 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, float 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 clearHover() {
        hoverX = -1;
        hoverY = -1;
        hoverSessionIndex = -1;
        hoverPoint = null;
        invalidate();
    }

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

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

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

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

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

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

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawColor(0xFF121212);
        if (sessions.isEmpty() || !hasPlottableData()) {
            textPaint.setTextSize(28f);
            canvas.drawText(sessions.isEmpty() ? "Select session(s)" : "No time-series samples",
                    PAD_LEFT, PAD_TOP + 32f, textPaint);
            return;
        }

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

        List<ChartTickHelper.Tick> xTicks = ChartTickHelper.timeMs(
                (long) viewMinT, (long) viewMaxT, 7);
        List<ChartTickHelper.Tick> yTicks = ChartTickHelper.linear(viewMinY, viewMaxY, 5);

        for (ChartTickHelper.Tick t : xTicks) {
            float x = toScreenX(t.value);
            canvas.drawLine(x, PAD_TOP, x, PAD_TOP + plotH, gridPaint);
        }
        for (ChartTickHelper.Tick t : yTicks) {
            float y = toScreenY(t.value);
            canvas.drawLine(PAD_LEFT, y, PAD_LEFT + plotW, y, gridPaint);
        }

        canvas.drawLine(PAD_LEFT, PAD_TOP + plotH, PAD_LEFT + plotW, PAD_TOP + plotH, axisPaint);
        canvas.drawLine(PAD_LEFT, PAD_TOP, PAD_LEFT, PAD_TOP + plotH, axisPaint);

        textPaint.setTextSize(20f);
        textPaint.setColor(0xFFAAAAAA);
        for (ChartTickHelper.Tick t : xTicks) {
            float x = toScreenX(t.value);
            canvas.drawText(t.label, x - 20f, PAD_TOP + plotH + 32f, textPaint);
        }
        for (ChartTickHelper.Tick t : yTicks) {
            float y = toScreenY(t.value);
            canvas.drawText(t.label, 4f, y + 6f, textPaint);
        }

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

        drawLegend(canvas, plotW);

        if (hoverPoint != null && hoverSessionIndex >= 0) {
            int color = SESSION_COLORS[hoverSessionIndex % SESSION_COLORS.length];
            markerFill.setColor(color);
            canvas.drawCircle(hoverPlotX, hoverPlotY, 8f, markerFill);
            canvas.drawCircle(hoverPlotX, hoverPlotY, 8f, markerStroke);
            drawHoverPopup(canvas, color);
        }
    }

    private boolean hasPlottableData() {
        for (SessionRecord doc : sessions) {
            if (doc.samples.size() >= 2) {
                return true;
            }
        }
        return false;
    }

    private void drawSessionLine(Canvas canvas, SessionRecord doc) {
        Path path = new Path();
        boolean first = true;
        for (SessionRecord.SamplePoint p : doc.samples) {
            if (p.tMs < viewMinT || p.tMs > viewMaxT) {
                continue;
            }
            double v = metricValue(p);
            if (v < viewMinY || v > viewMaxY) {
                continue;
            }
            float x = toScreenX(p.tMs);
            float y = toScreenY(v);
            if (first) {
                path.moveTo(x, y);
                first = false;
            } else {
                path.lineTo(x, y);
            }
        }
        canvas.drawPath(path, linePaint);
    }

    private void drawLegend(Canvas canvas, float plotW) {
        int rows = 0;
        for (SessionRecord doc : sessions) {
            if (doc.samples.size() >= 2) {
                rows++;
            }
        }
        if (rows == 0) {
            return;
        }
        float legendW = 280f;
        float rowH = 28f;
        float legendH = rows * rowH + 16f;
        float x0 = PAD_LEFT + plotW - legendW - 8f;
        float y0 = PAD_TOP + 8f;
        canvas.drawRoundRect(x0, y0, x0 + legendW, y0 + legendH, 8f, 8f, legendBgPaint);
        canvas.drawRoundRect(x0, y0, x0 + legendW, y0 + legendH, 8f, 8f, legendBorderPaint);

        int row = 0;
        for (int si = 0; si < sessions.size(); si++) {
            SessionRecord doc = sessions.get(si);
            if (doc.samples.size() < 2) {
                continue;
            }
            int color = SESSION_COLORS[si % SESSION_COLORS.length];
            float ry = y0 + 8f + row * rowH;
            swatchPaint.setColor(color);
            canvas.drawRoundRect(x0 + 10f, ry, x0 + 28f, ry + 16f, 3f, 3f, swatchPaint);
            legendBorderPaint.setStyle(Paint.Style.STROKE);
            canvas.drawRoundRect(x0 + 10f, ry, x0 + 28f, ry + 16f, 3f, 3f, legendBorderPaint);
            textPaint.setColor(0xFFFFFFFF);
            textPaint.setTextSize(20f);
            textPaint.setTypeface(si == hoverSessionIndex ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT);
            String name = doc.fileName;
            if (name.length() > 32) {
                name = "…" + name.substring(name.length() - 31);
            }
            canvas.drawText(name, x0 + 36f, ry + 14f, textPaint);
            row++;
        }
        textPaint.setTypeface(Typeface.DEFAULT);
    }

    private void drawHoverPopup(Canvas canvas, int accentColor) {
        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);
        float tw = tooltipFg.measureText(text) + 24f;
        float th = 36f;
        float px = hoverPlotX + 14f;
        float py = hoverPlotY - th - 16f;
        if (px + tw > PAD_LEFT + plotWidth()) {
            px = hoverPlotX - tw - 14f;
        }
        if (py < PAD_TOP) {
            py = hoverPlotY + 14f;
        }
        tooltipBg.setColor(0xE0000000);
        canvas.drawRoundRect(px, py, px + tw, py + th, 8f, 8f, tooltipBg);
        markerStroke.setColor(accentColor);
        canvas.drawRoundRect(px, py, px + tw, py + th, 8f, 8f, markerStroke);
        markerStroke.setColor(0xFFFFFFFF);
        canvas.drawText(text, px + 12f, py + 24f, tooltipFg);
    }

    private final class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            float factor = 1f / detector.getScaleFactor();
            zoomAt(detector.getFocusX(), detector.getFocusY(), factor);
            invalidate();
            return true;
        }
    }
}
