// lib/editor/canvas/pen_editor_screen.dart
//
// Pen-first PDF editor built on pdfrx's REAL vector PdfViewer (continuous
// scroll, native pan/zoom, selectable text), NOT the old single-page bitmap
// (PdfPageView). Three glued-to-page layers ride on top of the viewer:
//
//   1. Per-page ink overlay (pageOverlaysBuilder): committed strokes + the
//      live in-progress stroke + stored text highlights, all in NORMALIZED
//      page coords so they stay pinned under scroll/zoom.
//   2. A viewer-level PenCaptureRegion (viewerOverlayBuilder) that captures
//      stylus events ONLY when a pen tool is active; touch/mouse fall through
//      to pdfrx for scroll/zoom/text-selection. Pen samples are mapped
//      global → document → (pageIndex, normalized) via the controller.
//   3. The PdfViewer itself owns text selection; a "select text" tool disables
//      pen capture so the pen drives pdfrx text selection, and a
//      "highlight selection" action turns the current selection into stored
//      normalized highlight rects.
//
// Persistence (Phase 2): strokes, highlights and scratch-links all write to a
// per-file SIDECAR (`<pdfPath>.badnote.json`) via SidecarRepository — debounced,
// atomic (temp+rename+.bak). The source file PATH is the identity (the sidecar
// IS the identity; the old djb2 path-hash document id is gone). Highlights now
// survive reopen, and a stored highlight can be removed (the un-highlight tool).

import 'package:flutter/foundation.dart'
    show ValueListenable, visibleForTesting;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:uuid/uuid.dart';

import '../../l10n/app_localizations.dart';
import '../../models/scratch_link.dart';
import '../../screens/split_view_screen.dart';
import '../../storage/badnote_sidecar.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_simplify.dart';
import '../engine/undo_stack.dart';
import '../input/diagnostic_logger.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart'
    show PressureCurve, kNaturalPressureFloor;
import '../pdf/pen_capture_region.dart';
import '../persistence/sidecar_repository.dart';
import '../ui/pen_settings_page.dart';
import '../ui/thumbnail_grid.dart';
import 'editor_tool.dart';
import 'ink_painters.dart' show buildStrokePath;
import 'input_diagnostics.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
import 'pinch_scale_solver.dart';

/// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space
/// (not scaled with zoom) so the tap target stays comfortably tappable.
const double _kMarkerSize = 36.0;

class PenEditorScreen extends StatefulWidget {
  const PenEditorScreen({
    super.key,
    required this.pdfPath,
    this.initialPage = 0,
  });

  final String pdfPath;

  /// 0-based page to open on (e.g. a search-result jump). Clamped to the
  /// document's page range once it loads.
  final int initialPage;

  @override
  State<PenEditorScreen> createState() => _PenEditorScreenState();
}

class _PenEditorScreenState extends State<PenEditorScreen> {
  /// pdfrx viewer controller. Owns native pan/zoom and gives us the page
  /// layout rects + coordinate conversions used to map pen events and to
  /// re-project normalized strokes back to viewer pixels.
  final PdfViewerController _controller = PdfViewerController();

  bool _viewerReady = false;

  /// Total page count (0 until the document is laid out).
  int _pageCount = 0;

  /// 0-based current page index (the page pdfrx reports as current). Drives the
  /// page pill + thumbnail highlight.
  int _pageIndex = 0;

  /// Strokes per page, keyed by 0-based page index (normalized coords).
  final Map<int, List<PenStroke>> _strokesByPage = {};

  /// Text highlights per page, keyed by 0-based page index. Each Rect is in
  /// NORMALIZED page coords (left/top/right/bottom in [0,1]) so it stays glued
  /// under zoom. Persisted to the sidecar (Phase 2 — highlights now survive
  /// reopen and can be removed via the un-highlight tool).
  final Map<int, List<Rect>> _highlightsByPage = {};

  /// Per-page undo/redo history. Snapshot-before-change discipline: the
  /// pre-mutation stroke list is recorded before each commit/erase.
  final Map<int, UndoStack<List<PenStroke>>> _undo = {};

  UndoStack<List<PenStroke>> _undoFor(int page) =>
      _undo.putIfAbsent(page, () => UndoStack<List<PenStroke>>());

  /// Pen input configuration (widths, finger drawing, button actions).
  /// Loaded asynchronously in initState; null until ready.
  PenConfigController? _penConfig;

  // ── Persistence ────────────────────────────────────────────────────────────

  /// Per-file sidecar persistence. Identity is the source file PATH (the
  /// sidecar IS the identity — no more djb2 path-hash document id). Null until
  /// [_initPersistence] resolves.
  SidecarRepository? _repo;

  /// Repaint signal for the page overlays. Bumped on every pen move/commit/erase
  /// and on every viewer transform so the per-page CustomPaint re-projects the
  /// live stroke + committed ink to the current zoom.
  final ValueNotifier<int> _overlayRepaint = ValueNotifier<int>(0);
  void _bumpOverlay() => _overlayRepaint.value++;

  // ── Glitch-guarded pinch zoom (we own scale; pdfrx owns scroll) ─────────────
  // pdfrx's scaleEnabled is off, so we drive 2-finger zoom through the public
  // controller (zoomOnLocalPosition) with the SAME guards as the note canvas's
  // PenInteractiveViewer: per-frame scale-ratio clamp, pointer-count re-baseline
  // to the last APPLIED scale, and a focal-jump guard. Tracking is absolute from
  // a gesture-start snapshot via absolutePinchScale() — never a live read-back.

  static const double _kPinchMinScale = 0.5;
  static const double _kPinchMaxScale = 8.0;

  /// A real pinch changes scale modestly per frame; a frame demanding far more
  /// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
  static const double _kScaleGlitchHi = 1.4;
  static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;

  /// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
  static const double _kFocalGlitchPx = 250.0;

  /// Matrix scale captured at the current baseline (gesture start or the last
  /// pointer-count re-baseline). Null when no pinch is active.
  double? _pinchScaleStart;

  /// Pointer count of the previous accepted pinch frame; a change re-baselines.
  int _pinchPointerCount = 0;

  /// The recognizer's cumulative `details.scale` on the previous accepted frame.
  double _pinchLastRawScale = 1.0;

  /// The absolute scale we last APPLIED. Re-baseline anchors to THIS (not a live
  /// matrix read) so the displayed scale stays continuous across a finger blip.
  double _pinchLastAppliedScale = 1.0;

  /// The recognizer's cumulative `details.scale` at the current baseline; the
  /// absolute target normalizes against it (see [absolutePinchScale]).
  double _pinchRawScaleAtBaseline = 1.0;

  // ── Live stroke state (viewer-level pen capture) ────────────────────────────

  /// The page index the in-progress stroke belongs to (the page of its first
  /// point). A stroke lives on exactly ONE page; samples on other pages are
  /// ignored. Null when idle.
  int? _liveStrokePage;

  /// In-progress stroke points (normalized to [_liveStrokePage]).
  final List<PenPoint> _livePoints = [];

  /// CURRENT live stroke, published to the page overlay painters. The painter
  /// reads this at PAINT time (not as a build-time snapshot), so a mid-stroke
  /// update repaints immediately — the ink follows the pen instead of only
  /// appearing on pointer-up. Null when idle.
  ///
  /// (The previous design passed a snapshot into the painter's constructor from
  /// pageOverlaysBuilder, which only re-runs on setState; _bumpOverlay repainted
  /// the painter but it still read the stale build-time snapshot → invisible ink
  /// until commit. See `_LiveStrokeData` / `_PageOverlayPainter`.)
  final ValueNotifier<_LiveStrokeData?> _liveStrokeVN = ValueNotifier(null);

  /// Latest pen-event debug readout — shown only when the diagnostic toggle is
  /// on, to inspect what Windows delivers.
  String _penDebug = '';
  bool _showPenDebug = false;
  double _peakNorm = 0;

  // Tool state. The single shared active-tool enum; the page-anchored
  // select-text / place-link tools (below) are PDF-only and ride a different
  // path (they disable pen capture), so they stay as their own booleans.
  EditorToolKind _tool = EditorToolKind.brush;

  /// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
  /// highlighter tool always uses [BrushKind.highlighter]. Local state only for
  /// this increment (not persisted — TODO(brush-persist-selection)).
  BrushKind _penBrush = BrushKind.fountainPen;

  /// Selected shape for the SHAPE tool.
  ShapeKind _shapeKind = ShapeKind.line;

  /// SELECT tool: ([page], strokeIndex) of the selected committed stroke, or
  /// null. Selection is per-page (each page has its own stroke list).
  ({int page, int index})? _selected;

  /// SHAPE tool: normalized start point + its page, while a shape drag is live.
  ({int page, Offset start})? _shapeDrag;

  /// SELECT tool: last normalized drag position + page, to compute the
  /// incremental translation; and whether the drag's undo snapshot was taken.
  Offset? _selectLast;
  bool _selectDragging = false;

  /// rnote-style per-brush color memory: each brush (and the highlighter)
  /// remembers its own color. Selecting a brush restores its color; picking a
  /// color updates ONLY the active brush's entry. In-memory only for this
  /// increment (TODO(brush-color-persist)).
  final Map<BrushKind, Color> _brushColors = {
    BrushKind.fountainPen: Colors.black,
    BrushKind.ballpoint: Colors.blue,
    BrushKind.pencil: Colors.green,
    BrushKind.highlighter: Colors.orange,
  };

  /// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter,
  /// else the selected pen brush).
  BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
      ? BrushKind.highlighter
      : _penBrush;

  /// When true the "select text" tool is active: pen capture is disabled so the
  /// pen falls through to pdfrx for native text selection.
  bool _selectTextMode = false;

  /// When true the "place scratch link" tool is active: a tap on a page drops a
  /// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled
  /// so the tap is handled by the per-page GestureDetector overlay.
  bool _placeLinkMode = false;

  /// When true the "remove highlight" tool is active: a tap on an existing
  /// highlight rect deletes it (the un-highlight action). Pen capture is
  /// disabled so the tap is handled by the per-page GestureDetector overlay.
  bool _removeHighlightMode = false;

  /// All scratch-link anchors for this document, loaded on open and updated on
  /// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
  final List<ScratchLink> _scratchLinks = [];

  static const _uuid = Uuid();

  /// The active drawing color = the active brush's remembered color.
  Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
  bool _allowFingerDrawing = false;

  /// Whether the viewer currently has a non-empty text selection (drives the
  /// "highlight selection" action's enabled state).
  bool _hasSelection = false;

  /// Pen width as a fraction of page width (base; pressure thins it down).
  static const double _penWidthFraction = 0.006;
  static const double _highlighterWidthFraction = 0.02;

  static const List<Color> _palette = [
    Colors.black,
    Colors.red,
    Colors.blue,
    Colors.green,
    Colors.orange,
  ];

  /// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
  /// pen capture is on. False in select-text mode (pen reaches pdfrx text
  /// selection) and in place-link mode (a tap drops an anchor via the overlay).
  bool get _penCaptureEnabled =>
      !_selectTextMode && !_placeLinkMode && !_removeHighlightMode;

  /// True when the eraser tool is active.
  bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;

  /// True when the SELECT tool is active (and not in a page-anchored mode).
  bool get _isSelect =>
      _tool == EditorToolKind.select && _penCaptureEnabled;

  /// True when the SHAPE tool is active.
  bool get _isShape => _tool == EditorToolKind.shape && _penCaptureEnabled;

  @override
  void initState() {
    super.initState();
    // Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
    // No-op on platforms without the plugin (W3).
    PenInputService.instance.start();
    _initPersistence();
    _initPenConfig();
  }

  Future<void> _initPenConfig() async {
    final controller = await PenConfigController.load();
    if (!mounted) {
      controller.dispose();
      return;
    }
    controller.addListener(_onPenConfigChanged);
    setState(() {
      _penConfig = controller;
      _allowFingerDrawing = controller.value.fingerDrawing;
    });
  }

  void _onPenConfigChanged() {
    if (mounted) setState(() {});
  }

  Future<void> _initPersistence() async {
    final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
    if (!mounted) {
      repo.dispose();
      return;
    }
    _repo = repo;
    _loadFromSidecar(repo);
  }

  /// Hydrate the in-memory editor state from the sidecar [repo] loaded on open:
  /// per-page strokes, per-page highlights, and scratch-link anchors.
  void _loadFromSidecar(SidecarRepository repo) {
    final loadedStrokes = <int, List<PenStroke>>{};
    for (final entry in repo.loadedStrokes.entries) {
      loadedStrokes[entry.key] = entry.value
          .map((es) => PenStroke(
                points: es.points
                    .map((ep) =>
                        PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
                    .toList(),
                color: es.color,
                width: es.width,
                kind: es.tool == EditorTool.highlighter
                    ? PenStrokeKind.highlighter
                    : PenStrokeKind.pen,
                // Brush isn't persisted yet (TODO(brush-persist)); derive it
                // from the tool so a loaded highlighter still renders with the
                // highlighter brush (flat width), and pens fall back to the
                // fountainPen default.
                brush: es.tool == EditorTool.highlighter
                    ? BrushKind.highlighter
                    : BrushKind.fountainPen,
              ))
          .toList();
    }

    final loadedHighlights = <int, List<Rect>>{};
    for (final entry in repo.loadedHighlights.entries) {
      loadedHighlights[entry.key] =
          entry.value.map((h) => h.toRect()).toList();
    }

    setState(() {
      for (final entry in loadedStrokes.entries) {
        _strokesByPage[entry.key] = entry.value;
      }
      for (final entry in loadedHighlights.entries) {
        _highlightsByPage[entry.key] = entry.value;
      }
      _scratchLinks
        ..clear()
        ..addAll(repo.loadedScratchLinks.map((s) => s.link));
    });
    _bumpOverlay();
  }

  @override
  void dispose() {
    // Flush any pending sidecar write before tearing down.
    final repo = _repo;
    if (repo != null) {
      repo.flush(); // fire-and-forget; atomic write completes off the UI tree
      repo.dispose();
    }
    _overlayRepaint.dispose();
    _liveStrokeVN.dispose();
    _penConfig?.dispose();
    PenInputService.instance.stop();
    DiagnosticLogger.instance.stop();
    super.dispose();
  }

  // ── Stroke persistence (reused verbatim from the bitmap editor) ────────────

  void _commitStroke(int pageIndex, PenStroke stroke) {
    _undoFor(pageIndex)
        .record(List<PenStroke>.of(_strokesByPage[pageIndex] ?? const []));
    setState(() {
      // New list identity so the overlay painter sees a change.
      _strokesByPage[pageIndex] = [
        ...?_strokesByPage[pageIndex],
        stroke,
      ];
    });
    final snapshot = List<PenStroke>.of(_strokesByPage[pageIndex]!);
    _schedulePageSave(pageIndex, snapshot);
    _bumpOverlay();
  }

  /// Replace committed stroke [index] on [pageIndex] with its surviving pieces
  /// after a partial (segment) erase. An empty [replacements] removes it.
  void _eraseStroke(int pageIndex, int index, List<PenStroke> replacements) {
    final list = _strokesByPage[pageIndex];
    final willMutate = list != null && index >= 0 && index < list.length;
    if (willMutate) {
      _undoFor(pageIndex).record(List<PenStroke>.of(list));
    }
    setState(() {
      if (list != null && index >= 0 && index < list.length) {
        final next = List<PenStroke>.of(list)
          ..replaceRange(index, index + 1, replacements);
        _strokesByPage[pageIndex] = next;
      }
    });
    final current = _strokesByPage[pageIndex];
    final snapshot =
        current != null ? List<PenStroke>.of(current) : <PenStroke>[];
    _schedulePageSave(pageIndex, snapshot);
    _bumpOverlay();
  }

  /// Convert [strokes] to [EditorStroke]s and hand them to the sidecar repo.
  void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
    final repo = _repo;
    if (repo == null) return;
    final editorStrokes = strokes
        .map((s) => simplifyStroke(EditorStroke.fromPenStroke(s)))
        .toList();
    repo.scheduleStrokeSave(pageIndex, editorStrokes);
  }

  void _performUndo() {
    final stack = _undoFor(_pageIndex);
    if (!stack.canUndo) return;
    final current = List<PenStroke>.of(_strokesByPage[_pageIndex] ?? const []);
    final snapshot = stack.undo(current);
    if (snapshot == null) return;
    setState(() {
      _strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
    });
    _schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
    _bumpOverlay();
  }

  void _performRedo() {
    final stack = _undoFor(_pageIndex);
    if (!stack.canRedo) return;
    final snapshot = stack.redo();
    if (snapshot == null) return;
    setState(() {
      _strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
    });
    _schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
    _bumpOverlay();
  }

  // ── Pen capture (viewer-level) ─────────────────────────────────────────────

  /// Normalize stylus pressure to [0,1] shaped by the configured curve, or null
  /// when the device reports no usable pressure range (freehand simulates it).
  double? _normalizedPressure(PointerEvent event) {
    final raw = _rawNormalizedPressure(event);
    if (raw == null) return null;
    // PenConfig exposes gamma but not floor; use the shared natural floor (the
    // bitmap editor did the same — it never sourced floor from config). The
    // gamma is the BRUSH's pressure warp (fountain p² / pencil √p / linear),
    // superseding the legacy config gamma — see TODO(brush-pressure-knob).
    const floor = kNaturalPressureFloor;
    final gamma = brushProfileFor(_currentBrush()).pressureGamma;
    return PressureCurve(floor: floor, gamma: gamma).apply(raw);
  }

  double? _rawNormalizedPressure(PointerEvent event) {
    final range = event.pressureMax - event.pressureMin;
    if (range > 0.0001) {
      return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
    }
    if (event.pressure > 0.0 && event.pressure < 1.0) {
      return event.pressure;
    }
    return null;
  }

  /// Map a global pen position to (pageIndex, normalized-in-page) using the
  /// controller's document-space page layout rects. Returns null if outside
  /// every page box or the viewer isn't ready.
  ({int page, Offset normalized})? _documentToPage(Offset global) {
    if (!_controller.isReady) return null;
    final doc = _controller.globalToDocument(global);
    if (doc == null) return null;
    final rects = _controller.layout.pageLayouts;
    for (var i = 0; i < rects.length; i++) {
      final r = rects[i];
      if (r.contains(doc)) {
        final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
        final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
        return (page: i, normalized: Offset(nx, ny));
      }
    }
    return null;
  }

  void _onPenEvent(PointerEvent event) {
    if (_isStylus(event.kind)) _emitPenDebug(event);

    final hit = _documentToPage(event.position);

    if (event is PointerDownEvent) {
      if (hit == null) return;
      if (_isEraser) {
        _liveStrokePage = hit.page;
        _eraseAt(hit.page, hit.normalized);
        return;
      }
      if (_isSelect) {
        _liveStrokePage = hit.page;
        _selectLast = hit.normalized;
        _selectDragging = false;
        _selectAt(hit.page, hit.normalized);
        return;
      }
      if (_isShape) {
        _liveStrokePage = hit.page;
        _shapeDrag = (page: hit.page, start: hit.normalized);
        return;
      }
      _liveStrokePage = hit.page;
      _livePoints
        ..clear()
        ..add(PenPoint(hit.normalized.dx, hit.normalized.dy,
            _normalizedPressure(event)));
      _updateLiveStroke();
    } else if (event is PointerMoveEvent) {
      final page = _liveStrokePage;
      if (page == null) return;
      if (_isEraser) {
        // Erase only against the page the gesture started on; ignore drift.
        if (hit != null && hit.page == page) {
          _eraseAt(page, hit.normalized);
        }
        return;
      }
      if (_isSelect) {
        if (hit == null || hit.page != page) return;
        _dragSelected(page, hit.normalized);
        return;
      }
      if (_isShape) {
        if (hit == null || hit.page != page) return;
        _updateShapePreview(page, hit.normalized);
        return;
      }
      // A stroke belongs to ONE page: ignore samples on a different page.
      if (hit == null || hit.page != page) return;
      _livePoints.add(PenPoint(
          hit.normalized.dx, hit.normalized.dy, _normalizedPressure(event)));
      _updateLiveStroke();
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      _endStroke(commit: event is PointerUpEvent);
    }
  }

  /// SELECT down: hit-test the page's committed strokes (topmost first) and set
  /// the selection (or clear it on empty space).
  void _selectAt(int page, Offset normalized) {
    final strokes = _strokesByPage[page];
    final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
    final aspect = _pageAspect(page);
    int? hitIndex;
    if (strokes != null) {
      for (var i = strokes.length - 1; i >= 0; i--) {
        if (strokeHit(strokes[i], normalized.dx, normalized.dy, radius,
            aspect: aspect)) {
          hitIndex = i;
          break;
        }
      }
    }
    setState(() {
      _selected = hitIndex == null ? null : (page: page, index: hitIndex);
    });
    _bumpOverlay();
  }

  /// SELECT drag: translate the selected stroke by the incremental delta,
  /// recording ONE undo snapshot on the first delta of the drag.
  void _dragSelected(int page, Offset normalized) {
    final sel = _selected;
    final last = _selectLast;
    if (sel == null || last == null || sel.page != page) {
      _selectLast = normalized;
      return;
    }
    final dx = normalized.dx - last.dx;
    final dy = normalized.dy - last.dy;
    _selectLast = normalized;
    if (dx == 0 && dy == 0) return;
    _moveSelected(dx, dy, isDragStart: !_selectDragging);
    _selectDragging = true;
  }

  /// Translate the selected stroke by ([dx],[dy]); persists + (on [isDragStart])
  /// records one undo snapshot via the existing per-page undo stack.
  void _moveSelected(double dx, double dy, {required bool isDragStart}) {
    final sel = _selected;
    if (sel == null) return;
    final list = _strokesByPage[sel.page];
    if (list == null || sel.index < 0 || sel.index >= list.length) return;
    if (isDragStart) _undoFor(sel.page).record(List<PenStroke>.of(list));
    setState(() {
      final next = List<PenStroke>.of(list);
      next[sel.index] = translateStroke(next[sel.index], dx, dy);
      _strokesByPage[sel.page] = next;
    });
    _schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
    _bumpOverlay();
  }

  /// Delete the selected stroke (button or long-press) as one undo step.
  void _deleteSelected() {
    final sel = _selected;
    if (sel == null) return;
    final list = _strokesByPage[sel.page];
    if (list == null || sel.index < 0 || sel.index >= list.length) return;
    _undoFor(sel.page).record(List<PenStroke>.of(list));
    setState(() {
      final next = List<PenStroke>.of(list)..removeAt(sel.index);
      _strokesByPage[sel.page] = next;
      _selected = null;
    });
    _schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
    _bumpOverlay();
  }

  /// SHAPE preview: rebuild the generated shape stroke from start→current and
  /// publish it as the live stroke (drawn by the page overlay painter).
  void _updateShapePreview(int page, Offset current) {
    final drag = _shapeDrag;
    if (drag == null || drag.page != page) return;
    final pts = generateShapePoints(
      _shapeKind,
      PenPoint(drag.start.dx, drag.start.dy, 1.0),
      PenPoint(current.dx, current.dy, 1.0),
    );
    _liveStrokeVN.value = _LiveStrokeData(
      page,
      PenStroke(
        points: pts,
        color: _currentColor().toARGB32(),
        width: _currentStrokeWidth(),
        kind: PenStrokeKind.pen,
        brush: _currentBrush(),
      ),
    );
  }

  void _updateLiveStroke() {
    final page = _liveStrokePage;
    if (page == null || _livePoints.isEmpty) return;
    final stroke = PenStroke(
      points: List.of(_livePoints),
      color: _currentColor().toARGB32(),
      width: _currentStrokeWidth(),
      kind: _currentKind(),
      brush: _currentBrush(),
    );
    // Publish the live stroke so the page overlay painter repaints it NOW
    // (notifies its repaint Listenable) — the ink follows the pen.
    _liveStrokeVN.value = _LiveStrokeData(page, stroke);
  }

  void _endStroke({required bool commit}) {
    final page = _liveStrokePage;
    // SHAPE: on release, commit the generated shape stroke.
    final drag = _shapeDrag;
    if (_isShape && drag != null && commit) {
      final end = _liveStrokeVN.value;
      if (end != null && end.page == drag.page) {
        _commitStroke(drag.page, end.stroke);
      }
    } else if (page != null &&
        commit &&
        !_isEraser &&
        !_isSelect &&
        !_isShape &&
        _livePoints.isNotEmpty) {
      // A single tap → tiny dot is allowed (perfect_freehand renders a dot for
      // a 1-point stroke).
      _commitStroke(
        page,
        PenStroke(
          points: List.of(_livePoints),
          color: _currentColor().toARGB32(),
          width: _currentStrokeWidth(),
          kind: _currentKind(),
          brush: _currentBrush(),
        ),
      );
    }
    _liveStrokePage = null;
    _shapeDrag = null;
    _selectLast = null;
    _selectDragging = false;
    _livePoints.clear();
    _liveStrokeVN.value = null;
    _bumpOverlay();
  }

  /// Partial (segment) erase on [pageIndex]: find the first committed stroke the
  /// eraser circle touches and replace it with its surviving pieces.
  void _eraseAt(int pageIndex, Offset normalized) {
    final strokes = _strokesByPage[pageIndex];
    if (strokes == null || strokes.isEmpty) return;
    final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
    final wholeStroke = _penConfig?.value.eraserWholeStroke ?? false;
    final aspect = _pageAspect(pageIndex);
    for (var i = strokes.length - 1; i >= 0; i--) {
      final stroke = strokes[i];
      if (!strokeHit(stroke, normalized.dx, normalized.dy, radius,
          aspect: aspect)) {
        continue;
      }
      final pieces = wholeStroke
          ? const <PenStroke>[]
          : splitStrokeByCircle(stroke, normalized.dx, normalized.dy, radius,
              aspect: aspect);
      if (pieces.length == 1 && identical(pieces.first, stroke)) return;
      _eraseStroke(pageIndex, i, pieces);
      return;
    }
  }

  /// Page aspect (height / width) so the eraser circle stays round on screen.
  double _pageAspect(int pageIndex) {
    if (!_controller.isReady) return 1.0;
    final rects = _controller.layout.pageLayouts;
    if (pageIndex < 0 || pageIndex >= rects.length) return 1.0;
    final r = rects[pageIndex];
    return r.width <= 0 ? 1.0 : r.height / r.width;
  }

  bool _isStylus(PointerDeviceKind kind) =>
      kind == PointerDeviceKind.stylus ||
      kind == PointerDeviceKind.invertedStylus;

  double _currentStrokeWidth() => _tool == EditorToolKind.highlighter
      ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
      : (_penConfig?.value.penWidth ?? _penWidthFraction);

  PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter
      ? PenStrokeKind.highlighter
      : PenStrokeKind.pen;

  /// Brush in effect: highlighter tool ⇒ highlighter brush, else the selected
  /// pen brush. Drives both the capture-time pressure warp and render geometry.
  BrushKind _currentBrush() => _tool == EditorToolKind.highlighter
      ? BrushKind.highlighter
      : _penBrush;

  Color _currentColor() => _tool == EditorToolKind.highlighter
      ? _color.withAlpha(0x80)
      : _color;

  void _emitPenDebug(PointerEvent event) {
    if (!_showPenDebug) return;
    final norm = _normalizedPressure(event);
    if (norm != null && norm > _peakNorm) _peakNorm = norm;
    setState(() {
      _penDebug = '${event.kind.name}  raw=${event.pressure.toStringAsFixed(1)}'
          '/${event.pressureMax.toStringAsFixed(0)}  '
          'norm=${norm?.toStringAsFixed(3) ?? "null"}  '
          'peak=${_peakNorm.toStringAsFixed(3)}  '
          'btn=${event.buttons}  tilt=${event.tilt.toStringAsFixed(2)}'
          '\n${PenInputService.instance.debugSummary}';
    });
  }

  // ── Pinch zoom (glitch-guarded, drives pdfrx controller) ────────────────────

  void _onPinchStart(ScaleStartDetails details) {
    if (!_controller.isReady) return;
    _pinchScaleStart = _controller.currentZoom;
    _pinchPointerCount = details.pointerCount;
    _pinchLastRawScale = 1.0;
    _pinchLastAppliedScale = _pinchScaleStart!;
    _pinchRawScaleAtBaseline = 1.0;
  }

  void _onPinchUpdate(ScaleUpdateDetails details) {
    final scaleStart = _pinchScaleStart;
    if (scaleStart == null || !_controller.isReady) return;

    // Re-baseline on any pointer-count change (a finger lands/lifts, or a
    // Windows touch 2↔1↔2 dropout). Anchor to the CLEAN tracked scale, not a
    // matrix read-back, so the displayed scale is continuous; skip this frame.
    if (details.pointerCount != _pinchPointerCount) {
      _pinchPointerCount = details.pointerCount;
      _pinchScaleStart = _pinchLastAppliedScale;
      _pinchLastRawScale = details.scale;
      _pinchRawScaleAtBaseline = details.scale;
      return;
    }

    // Per-frame finger-motion ratio from the recognizer's OWN cumulative scale.
    // A ratio outside the glitch band is a multi-touch spike → drop the frame;
    // absolute tracking means the next good frame resumes from the true span.
    final rawRatio =
        _pinchLastRawScale > 0 ? details.scale / _pinchLastRawScale : 1.0;
    final scaleDrop =
        rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
    final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
    if (scaleDrop || focalDrop) return;

    final targetScale = absolutePinchScale(
      scaleStart: _pinchScaleStart!,
      rawScaleAtBaseline: _pinchRawScaleAtBaseline,
      rawScale: details.scale,
      minScale: _kPinchMinScale,
      maxScale: _kPinchMaxScale,
    );

    // Focal zoom: keep the document point under the live focal (finger midpoint)
    // fixed, which also yields 2-finger pan for free as the focal moves.
    // localFocalPoint is in the viewer's local coords (the overlay fills it).
    _controller.zoomOnLocalPosition(
      localPosition: details.localFocalPoint,
      newZoom: targetScale,
      duration: Duration.zero,
    );

    _pinchLastRawScale = details.scale;
    _pinchLastAppliedScale = targetScale;
  }

  void _onPinchEnd(ScaleEndDetails details) {
    _pinchScaleStart = null;
    _pinchPointerCount = 0;
  }

  // ── Text selection → highlight ─────────────────────────────────────────────

  void _onTextSelectionChange(PdfTextSelection selection) {
    final has = selection.textSelectionPointRange != null;
    if (has != _hasSelection && mounted) {
      setState(() => _hasSelection = has);
    }
  }

  /// Read the current text selection, convert each selected fragment's PDF
  /// rectangle to a NORMALIZED page rect, store it in [_highlightsByPage], and
  /// clear the selection so the highlight is visible.
  Future<void> _highlightSelection() async {
    if (!_controller.isReady) return;
    final delegate = _controller.textSelectionDelegate;
    final ranges = await delegate.getSelectedTextRanges();
    if (!mounted || ranges.isEmpty) return;

    final added = <int, List<Rect>>{};
    final doc = _controller.document;
    for (final range in ranges) {
      final pageIndex = range.pageNumber - 1;
      if (pageIndex < 0 || pageIndex >= doc.pages.length) continue;
      final page = doc.pages[pageIndex];
      final w = page.width;
      final h = page.height;
      if (w <= 0 || h <= 0) continue;
      for (final frag in range.enumerateFragmentBoundingRects()) {
        // PDF-page-coords rect → unscaled Flutter page-pixel rect (scale 1 ⇒
        // page.size), then normalize by page size so it stays glued under zoom.
        final r = frag.bounds.toRect(page: page);
        final norm = Rect.fromLTRB(
          (r.left / w).clamp(0.0, 1.0),
          (r.top / h).clamp(0.0, 1.0),
          (r.right / w).clamp(0.0, 1.0),
          (r.bottom / h).clamp(0.0, 1.0),
        );
        if (norm.width <= 0 || norm.height <= 0) continue;
        (added[pageIndex] ??= <Rect>[]).add(norm);
      }
    }
    if (added.isEmpty) return;

    setState(() {
      for (final entry in added.entries) {
        (_highlightsByPage[entry.key] ??= <Rect>[]).addAll(entry.value);
      }
      _hasSelection = false;
    });
    // Persist each touched page's highlights to the sidecar (Phase 2 — closes
    // the old in-memory-only limitation; highlights now survive reopen).
    for (final pageIndex in added.keys) {
      _scheduleHighlightSave(pageIndex);
    }
    await delegate.clearTextSelection();
    _bumpOverlay();
  }

  /// Serialize the current highlights for [pageIndex] to the sidecar.
  void _scheduleHighlightSave(int pageIndex) {
    final repo = _repo;
    if (repo == null) return;
    final rects = _highlightsByPage[pageIndex] ?? const <Rect>[];
    repo.scheduleHighlightSave(
      pageIndex,
      rects.map((r) => SidecarHighlight.fromRect(r)).toList(),
    );
  }

  /// Remove the highlight rect on [pageIndex] under the normalized point
  /// [normalized] (topmost hit), persisting the change. Returns true if one was
  /// removed. This is the "un-highlight" action (a stored highlight could not be
  /// removed before): in SELECT-TEXT mode a tap on an existing highlight erases
  /// it.
  bool _removeHighlightAt(int pageIndex, Offset normalized) {
    final rects = _highlightsByPage[pageIndex];
    if (rects == null || rects.isEmpty) return false;
    for (var i = rects.length - 1; i >= 0; i--) {
      if (rects[i].contains(normalized)) {
        setState(() {
          final next = List<Rect>.of(rects)..removeAt(i);
          if (next.isEmpty) {
            _highlightsByPage.remove(pageIndex);
          } else {
            _highlightsByPage[pageIndex] = next;
          }
        });
        _scheduleHighlightSave(pageIndex);
        _bumpOverlay();
        return true;
      }
    }
    return false;
  }

  // ── Navigation / tools ─────────────────────────────────────────────────────

  void _goToPage(int index) {
    if (_pageCount == 0) return;
    final clamped = index.clamp(0, _pageCount - 1);
    _controller.goToPage(pageNumber: clamped + 1);
  }

  void _setTool(EditorToolKind tool) {
    setState(() {
      _tool = tool;
      _selectTextMode = false;
      _placeLinkMode = false;
      _removeHighlightMode = false;
      if (tool != EditorToolKind.select) _selected = null;
    });
  }

  void _enableSelectText() {
    setState(() {
      _selectTextMode = true;
      _placeLinkMode = false;
      _removeHighlightMode = false;
      _selected = null;
    });
  }

  /// Toggle "place scratch link" mode. While active, a tap on a page drops a
  /// new anchor at the tapped normalized position.
  void _togglePlaceLinkMode() {
    setState(() {
      _placeLinkMode = !_placeLinkMode;
      if (_placeLinkMode) {
        _selectTextMode = false;
        _removeHighlightMode = false;
      }
    });
  }

  /// Toggle "remove highlight" mode (the un-highlight action). While active, a
  /// tap on an existing highlight deletes it (persisted to the sidecar).
  void _toggleRemoveHighlightMode() {
    setState(() {
      _removeHighlightMode = !_removeHighlightMode;
      if (_removeHighlightMode) {
        _selectTextMode = false;
        _placeLinkMode = false;
        _selected = null;
      }
    });
  }

  // ── Scratch-link anchors ─────────────────────────────────────────────────────

  /// Create + persist a new anchor at normalized [normalized] on [pageIndex],
  /// then show it. Leaves place-link mode on so several can be dropped in a row.
  Future<void> _placeScratchLink(int pageIndex, Offset normalized) async {
    final link = ScratchLink(
      id: _uuid.v4(),
      // The source file path is the identity now (the sidecar IS the identity);
      // documentId is retained on the model only so the split view can address
      // the source PDF. Use the file path so it's stable across reopen.
      documentId: widget.pdfPath,
      pageIndex: pageIndex,
      nx: normalized.dx.clamp(0.0, 1.0),
      ny: normalized.dy.clamp(0.0, 1.0),
    );
    _repo?.scheduleScratchLinkUpsert(link);
    if (!mounted) return;
    setState(() => _scratchLinks.add(link));
  }

  /// Open the anchor's split view (left = this PDF at the anchor page, right =
  /// the anchor's private infinite scratchpad, stored in the sidecar).
  ///
  /// Flushes any pending sidecar write FIRST so the split view (which opens its
  /// own [SidecarRepository] on the same file) sees this anchor on disk before
  /// it writes the scratchpad back. On return, reload so any scratchpad change
  /// made there is reflected in this editor's in-memory sidecar repo.
  Future<void> _openScratchLink(ScratchLink link) async {
    await _repo?.flush();
    if (!mounted) return;
    await Navigator.of(context).push(
      MaterialPageRoute(
        builder: (_) => SplitViewScreen(
          filePath: widget.pdfPath,
          scratchLinkId: link.id,
          initialPage: link.pageIndex,
        ),
      ),
    );
    if (!mounted) return;
    // The split view wrote the scratchpad into the on-disk sidecar via its own
    // repo; re-open ours so subsequent saves here don't clobber that scratchpad.
    final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
    if (!mounted) {
      repo.dispose();
      return;
    }
    _repo?.dispose();
    _repo = repo;
  }

  /// Confirm + delete an anchor (and its private scratchpad).
  Future<void> _confirmDeleteScratchLink(ScratchLink link) async {
    final l = AppLocalizations.of(context);
    final confirmed = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text(l.scratchLinkDeleteTitle),
        content: Text(l.scratchLinkDeleteBody),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx, false),
            child: Text(l.cancel),
          ),
          TextButton(
            onPressed: () => Navigator.pop(ctx, true),
            child: Text(l.delete),
          ),
        ],
      ),
    );
    if (confirmed != true) return;
    _repo?.scheduleScratchLinkDelete(link.id);
    if (!mounted) return;
    setState(() => _scratchLinks.removeWhere((s) => s.id == link.id));
  }

  void _toggleFingerDrawing() {
    final next = !_allowFingerDrawing;
    setState(() => _allowFingerDrawing = next);
    _penConfig?.setFingerDrawing(next);
  }

  void _openThumbnails() {
    final doc = _controller.isReady ? _controller.document : null;
    if (doc == null) return;
    showPageThumbnailSheet(
      context,
      document: doc,
      currentPage: _pageIndex,
      onPageSelected: _goToPage,
    );
  }

  void _openPenSettings() {
    final config = _penConfig;
    if (config == null) return;
    showPenSettingsSheet(context, config);
  }

  // ── Build ──────────────────────────────────────────────────────────────────

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context);
    return Scaffold(
      body: Stack(
        children: [
          Positioned.fill(child: _buildViewer()),
          // Floating Material You tool palette (top-center).
          SafeArea(
            child: Align(
              alignment: Alignment.topCenter,
              child: Padding(
                padding: const EdgeInsets.only(top: 8),
                child: _buildToolPalette(),
              ),
            ),
          ),
          // Floating page-control pill (bottom-center).
          if (_viewerReady && _pageCount > 0)
            SafeArea(
              child: Align(
                alignment: Alignment.bottomCenter,
                child: Padding(
                  padding: const EdgeInsets.only(bottom: 16),
                  child: _buildPagePill(),
                ),
              ),
            ),
          // Back button (top-left).
          SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(8),
              child: RoundIconButton(
                icon: Icons.arrow_back,
                tooltip: l.back,
                onPressed: () => Navigator.of(context).maybePop(),
              ),
            ),
          ),
          if (_showPenDebug) _buildDebugReadout(context),
        ],
      ),
    );
  }

  Widget _buildViewer() {
    return PdfViewer.file(
      widget.pdfPath,
      controller: _controller,
      params: PdfViewerParams(
        // pdfrx keeps 1-finger scroll + wheel (panEnabled) but we TAKE OVER the
        // 2-finger pinch (scaleEnabled:false): pdfrx's forked InteractiveViewer
        // applies `desiredScale = _scaleStart * details.scale` with no per-frame
        // glitch guard, so a Windows-touch scale spike or pointer-count blip pops
        // the zoom and snaps back. We drive zoom ourselves via the glitch-guarded
        // _TwoFingerPinch recognizer in viewerOverlayBuilder → controller.
        // zoomOnLocalPosition (focal zoom). See _onPinchUpdate.
        panEnabled: true,
        scaleEnabled: false,
        // Native vector text selection. Pen falls through to this only in
        // select-text mode (PenCaptureRegion.captureEnabled == false).
        textSelectionParams: PdfTextSelectionParams(
          enabled: true,
          onTextSelectionChange: _onTextSelectionChange,
        ),
        onViewerReady: (document, controller) {
          if (!mounted) return;
          setState(() {
            _viewerReady = true;
            _pageCount = document.pages.length;
          });
          // Honor a requested initial page (search-result jump), clamped.
          final target = widget.initialPage.clamp(0, _pageCount - 1);
          if (target > 0) {
            controller.goToPage(pageNumber: target + 1);
          }
        },
        onPageChanged: (pageNumber) {
          if (pageNumber == null || !mounted) return;
          final idx = pageNumber - 1;
          if (idx != _pageIndex) setState(() => _pageIndex = idx);
        },
        // (1) Per-page overlay: committed ink + live stroke + highlights, all in
        // normalized page space scaled to the on-screen page rect.
        pageOverlaysBuilder: (context, pageRectInViewer, page) {
          final pageIndex = page.pageNumber - 1;
          final pageW = pageRectInViewer.width;
          final pageH = pageRectInViewer.height;
          final linksOnPage =
              _scratchLinks.where((l) => l.pageIndex == pageIndex);
          return [
            Positioned.fill(
              child: IgnorePointer(
                child: CustomPaint(
                  painter: _PageOverlayPainter(
                    overlayRepaint: _overlayRepaint,
                    liveStrokeVN: _liveStrokeVN,
                    pageIndex: pageIndex,
                    strokes: _strokesByPage[pageIndex] ?? const [],
                    highlights: _highlightsByPage[pageIndex] ?? const [],
                    selectedIndex:
                        (_selected != null && _selected!.page == pageIndex)
                            ? _selected!.index
                            : null,
                    pageSize: pageRectInViewer.size,
                    thinning: _penConfig?.value.pressureSensitivity ??
                        kDefaultPenThinning,
                  ),
                ),
              ),
            ),
            // Tap-to-place layer: only swallows taps while place-link mode is on.
            // Otherwise it's a no-op (IgnorePointer) so ink/scroll fall through.
            if (_placeLinkMode)
              Positioned.fill(
                child: GestureDetector(
                  behavior: HitTestBehavior.opaque,
                  onTapUp: (details) {
                    final local = details.localPosition;
                    if (pageW <= 0 || pageH <= 0) return;
                    final nx = (local.dx / pageW).clamp(0.0, 1.0);
                    final ny = (local.dy / pageH).clamp(0.0, 1.0);
                    _placeScratchLink(pageIndex, Offset(nx, ny));
                  },
                ),
              ),
            // Tap-to-remove-highlight layer (the un-highlight action): only
            // swallows taps while remove-highlight mode is on; a tap on an
            // existing highlight deletes it (persisted to the sidecar).
            if (_removeHighlightMode)
              Positioned.fill(
                child: GestureDetector(
                  behavior: HitTestBehavior.opaque,
                  onTapUp: (details) {
                    final local = details.localPosition;
                    if (pageW <= 0 || pageH <= 0) return;
                    final nx = (local.dx / pageW).clamp(0.0, 1.0);
                    final ny = (local.dy / pageH).clamp(0.0, 1.0);
                    _removeHighlightAt(pageIndex, Offset(nx, ny));
                  },
                ),
              ),
            // Anchor markers (sticky-note tabs): tap → split view, long-press →
            // delete. Sized in screen px so the tap target stays usable at any
            // zoom; positioned at (nx*pageW, ny*pageH).
            for (final link in linksOnPage)
              Positioned(
                left: link.nx * pageW - _kMarkerSize / 2,
                top: link.ny * pageH - _kMarkerSize / 2,
                width: _kMarkerSize,
                height: _kMarkerSize,
                child: _ScratchLinkMarker(
                  onTap: () => _openScratchLink(link),
                  onLongPress: () => _confirmDeleteScratchLink(link),
                ),
              ),
          ];
        },
        // (2) Viewer-level pen capture + our glitch-guarded pinch. Stylus is
        // captured ONLY when a pen tool is active; touch/mouse (and pen in
        // select-text mode) fall through to pdfrx for scroll/text-selection.
        // The pinch recognizer is touch-only and concedes the arena below 2
        // pointers, so 1-finger scroll still reaches pdfrx and the pen (captured
        // outside the arena by PenCaptureRegion) is never stolen.
        viewerOverlayBuilder: (context, size, handleLinkTap) {
          return [
            Positioned.fill(
              child: RawGestureDetector(
                // translucent (NOT opaque): the touch must ALSO hit-test pdfrx
                // underneath so its pan recognizer can win the 1-finger case.
                behavior: HitTestBehavior.translucent,
                gestures: {
                  _TwoFingerPinch:
                      GestureRecognizerFactoryWithHandlers<_TwoFingerPinch>(
                    () => _TwoFingerPinch(debugOwner: this),
                    (r) => r
                      ..onStart = _onPinchStart
                      ..onUpdate = _onPinchUpdate
                      ..onEnd = _onPinchEnd,
                  ),
                },
                child: const SizedBox.expand(),
              ),
            ),
            Positioned.fill(
              child: PenCaptureRegion(
                captureEnabled: _penCaptureEnabled,
                onPenEvent: _onPenEvent,
                child: const IgnorePointer(child: SizedBox.expand()),
              ),
            ),
          ];
        },
      ),
    );
  }

  /// Floating Material You tool palette.
  Widget _buildToolPalette() {
    final cs = Theme.of(context).colorScheme;
    final l = AppLocalizations.of(context);
    return Material(
      color: cs.surfaceContainerHigh,
      elevation: 3,
      borderRadius: BorderRadius.circular(28),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            BrushPickerButton(
              selected: _penBrush,
              active: _tool == EditorToolKind.brush && _penCaptureEnabled,
              tooltip: l.brushPicker,
              labelFor: (b) => brushLabel(b, l),
              colorFor: (b) => _brushColors[b] ?? Colors.black,
              onSelected: (b) {
                setState(() => _penBrush = b);
                _setTool(EditorToolKind.brush);
              },
            ),
            ToolButton(
              icon: Icons.brush_outlined,
              selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
              tooltip: l.toolHighlighter,
              onPressed: () => _setTool(EditorToolKind.highlighter),
            ),
            ToolButton(
              icon: Icons.cleaning_services_outlined,
              selected: _isEraser,
              tooltip: l.toolEraser,
              onPressed: () => _setTool(EditorToolKind.eraser),
            ),
            ToolButton(
              icon: Icons.ads_click,
              selected: _isSelect,
              tooltip: l.toolSelect,
              onPressed: () => _setTool(EditorToolKind.select),
            ),
            ShapePickerButton(
              selected: _shapeKind,
              active: _isShape,
              tooltip: l.shapePicker,
              labelFor: (s) => shapeLabel(s, l),
              onActivate: () => _setTool(EditorToolKind.shape),
              onSelected: (s) {
                setState(() => _shapeKind = s);
                _setTool(EditorToolKind.shape);
              },
            ),
            if (_isSelect && _selected != null)
              ToolButton(
                icon: Icons.delete_outline,
                selected: false,
                tooltip: l.actionDeleteSelection,
                onPressed: _deleteSelected,
              ),
            PaletteDivider(cs: cs),
            // Text selection + highlight (real vector text).
            ToolButton(
              icon: Icons.text_fields,
              selected: _selectTextMode,
              tooltip: l.toolSelectText,
              onPressed: _enableSelectText,
            ),
            ToolButton(
              icon: Icons.highlight,
              selected: false,
              tooltip: l.actionHighlightSelection,
              onPressed: _hasSelection ? _highlightSelection : null,
            ),
            // Un-highlight: toggle a mode where tapping an existing highlight
            // removes it (the user couldn't remove highlights before).
            ToolButton(
              icon: Icons.highlight_off,
              selected: _removeHighlightMode,
              tooltip: l.toolRemoveHighlight,
              onPressed: _toggleRemoveHighlightMode,
            ),
            PaletteDivider(cs: cs),
            // Place scratch link (sticky-note tab). Tap a page to drop an anchor.
            ToolButton(
              icon: Icons.sticky_note_2_outlined,
              selected: _placeLinkMode,
              tooltip: l.toolPlaceScratchLink,
              onPressed: _togglePlaceLinkMode,
            ),
            PaletteDivider(cs: cs),
            // Undo / redo (per page).
            ToolButton(
              icon: Icons.undo,
              selected: false,
              tooltip: l.actionUndo,
              onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
            ),
            ToolButton(
              icon: Icons.redo,
              selected: false,
              tooltip: l.actionRedo,
              onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
            ),
            PaletteDivider(cs: cs),
            for (final c in _palette) _colorDot(c, cs),
            PaletteDivider(cs: cs),
            ToolButton(
              icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
              selected: _allowFingerDrawing,
              tooltip:
                  _allowFingerDrawing ? l.fingerDrawingOn : l.fingerDrawingOff,
              onPressed: _toggleFingerDrawing,
            ),
            ToolButton(
              icon: Icons.grid_view,
              selected: false,
              tooltip: l.pages,
              onPressed: _viewerReady ? _openThumbnails : null,
            ),
            ToolButton(
              icon: Icons.settings_outlined,
              selected: false,
              tooltip: l.penSettings,
              onPressed: _penConfig != null ? _openPenSettings : null,
            ),
            ToolButton(
              icon: Icons.bug_report_outlined,
              selected: _showPenDebug,
              tooltip: l.inputDiagnostic,
              onPressed: () {
                final on = !_showPenDebug;
                setState(() => _showPenDebug = on);
                if (on) {
                  InputDiagnostics.instance.reset();
                  DiagnosticLogger.instance.start();
                } else {
                  DiagnosticLogger.instance.stop();
                }
              },
            ),
          ],
        ),
      ),
    );
  }

  Widget _colorDot(Color c, ColorScheme cs) {
    // Selected against the ACTIVE brush's remembered color; a tap updates only
    // that brush's entry (rnote per-brush color memory). Inert in select mode.
    final selected = _color == c && !_isSelect;
    return GestureDetector(
      onTap: () => setState(() => _brushColors[_activeColorBrush] = c),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        width: 28,
        height: 28,
        margin: const EdgeInsets.symmetric(horizontal: 3),
        decoration: BoxDecoration(
          color: c,
          shape: BoxShape.circle,
          border: Border.all(
            color: selected ? cs.primary : cs.outlineVariant,
            width: selected ? 3 : 1.5,
          ),
        ),
      ),
    );
  }

  /// Floating page control: a COMPACT pill (prev / "n / total" / next).
  Widget _buildPagePill() {
    final cs = Theme.of(context).colorScheme;
    final l = AppLocalizations.of(context);
    final total = _pageCount;
    final shown = _pageIndex + 1;
    return Material(
      color: cs.surfaceContainerHigh,
      elevation: 3,
      borderRadius: BorderRadius.circular(28),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            IconButton(
              tooltip: l.previousPage,
              icon: const Icon(Icons.chevron_left),
              onPressed:
                  _pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
            ),
            TextButton(
              onPressed: null,
              child: Text(
                l.pageOfPages(shown, total),
                style: TextStyle(
                  color: cs.onSurface,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ),
            IconButton(
              tooltip: l.nextPage,
              icon: const Icon(Icons.chevron_right),
              onPressed: _pageIndex < total - 1
                  ? () => _goToPage(_pageIndex + 1)
                  : null,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildDebugReadout(BuildContext context) {
    return SafeArea(
      child: Align(
        alignment: Alignment.topRight,
        child: Padding(
          padding: const EdgeInsets.all(8),
          child: Material(
            color: Theme.of(context).colorScheme.inverseSurface,
            borderRadius: BorderRadius.circular(8),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxWidth: 380),
              child: Padding(
                padding:
                    const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
                child: ListenableBuilder(
                  listenable: InputDiagnostics.instance,
                  builder: (context, _) {
                    final cs = Theme.of(context).colorScheme;
                    final d = InputDiagnostics.instance;
                    final tail = d.trace.length > 6
                        ? d.trace.sublist(d.trace.length - 6)
                        : d.trace;
                    final mono = TextStyle(
                        fontFamily: 'monospace',
                        fontSize: 11,
                        color: cs.onInverseSurface);
                    final monoFaint = mono.copyWith(
                        fontSize: 10,
                        color: cs.onInverseSurface.withValues(alpha: 0.75));
                    return Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Text(
                            _penDebug.isEmpty
                                ? 'hover / draw with the pen…'
                                : _penDebug,
                            style: mono),
                        const SizedBox(height: 4),
                        Text(d.summary(), style: mono),
                        if (tail.isNotEmpty) ...[
                          const SizedBox(height: 4),
                          Text(tail.join('\n'), style: monoFaint),
                        ],
                        const SizedBox(height: 4),
                        Text(
                            'log: ${DiagnosticLogger.instance.path ?? "(developer.log only)"}',
                            style: monoFaint),
                        Align(
                          alignment: Alignment.centerRight,
                          child: TextButton(
                            onPressed: () =>
                                InputDiagnostics.instance.reset(),
                            child: Text('Reset stats',
                                style:
                                    TextStyle(color: cs.inversePrimary)),
                          ),
                        ),
                      ],
                    );
                  },
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// A [ScaleGestureRecognizer] that competes ONLY for 2-finger touch gestures.
///
/// pdfrx's internal pan recognizer runs underneath (scaleEnabled is off, so it
/// still scrolls on 1 finger). A vanilla ScaleGestureRecognizer is eager: with a
/// single uncontested pointer it would win the arena and STEAL 1-finger scroll.
/// Restricting [supportedDevices] to touch keeps the stylus out (the pen is
/// captured outside the arena by [PenCaptureRegion]); rejecting the pointer while
/// fewer than 2 are down hands the 1-finger gesture back to pdfrx immediately,
/// and accepting on the 2nd finger lets us drive the pinch. Guarding inside
/// onUpdate would be too late — by then the arena is already won.
class _TwoFingerPinch extends ScaleGestureRecognizer {
  _TwoFingerPinch({super.debugOwner})
      : super(supportedDevices: const {PointerDeviceKind.touch});

  final Set<int> _pointers = <int>{};

  @override
  void addAllowedPointer(PointerDownEvent event) {
    _pointers.add(event.pointer);
    super.addAllowedPointer(event);
    if (_pointers.length < 2) {
      // Concede the 1-finger case now so pdfrx's pan wins without waiting.
      resolvePointer(event.pointer, GestureDisposition.rejected);
    } else {
      // Second finger down: claim the gesture before pdfrx treats it as a pan.
      resolve(GestureDisposition.accepted);
    }
  }

  @override
  void handleEvent(PointerEvent event) {
    if (event is PointerUpEvent || event is PointerCancelEvent) {
      _pointers.remove(event.pointer);
    }
    super.handleEvent(event);
  }

  @override
  void rejectGesture(int pointer) {
    _pointers.remove(pointer);
    super.rejectGesture(pointer);
  }
}

/// The currently in-progress stroke and the page it belongs to. Published via a
/// [ValueNotifier] so [_PageOverlayPainter] can read the live stroke at PAINT
/// time (driven by the notifier) instead of capturing a stale build-time
/// snapshot — that snapshot bug made ink appear only on pointer-up.
class _LiveStrokeData {
  const _LiveStrokeData(this.page, this.stroke);

  final int page;
  final PenStroke stroke;
}

/// Paints one page's overlay: text highlights (under), committed ink, then the
/// live in-progress stroke (over). Strokes are in normalized page coords; the
/// painter scales them to the on-screen page rect ([pageSize]) so they stay
/// glued to the page under pdfrx's native zoom/scroll.
///
/// The live stroke is read from [liveStrokeVN] at paint time (not passed as a
/// constructor snapshot) so that mid-stroke updates — which notify the merged
/// repaint Listenable — redraw the in-progress ink immediately. Committed
/// strokes + highlights still arrive via the constructor (they change only on
/// setState, which rebuilds this painter).
class _PageOverlayPainter extends CustomPainter {
  _PageOverlayPainter({
    required Listenable overlayRepaint,
    required this.liveStrokeVN,
    required this.pageIndex,
    required this.strokes,
    required this.highlights,
    required this.pageSize,
    required this.thinning,
    this.selectedIndex,
  }) : super(repaint: Listenable.merge([overlayRepaint, liveStrokeVN]));

  /// Live stroke source, read at paint time. Only painted when its page matches
  /// [pageIndex].
  final ValueListenable<_LiveStrokeData?> liveStrokeVN;
  final int pageIndex;
  final List<PenStroke> strokes;
  final List<Rect> highlights;
  final Size pageSize;
  final double thinning;

  /// SELECT tool: index into [strokes] of the selected stroke on this page, or
  /// null. Drives the selection bounding-box overlay.
  final int? selectedIndex;

  @override
  void paint(Canvas canvas, Size size) {
    // 1. Text highlights (semi-transparent yellow), normalized → pixels.
    if (highlights.isNotEmpty) {
      final hp = Paint()
        ..color = const Color(0x66FFEB3B)
        ..style = PaintingStyle.fill;
      for (final n in highlights) {
        canvas.drawRect(
          Rect.fromLTRB(
            n.left * size.width,
            n.top * size.height,
            n.right * size.width,
            n.bottom * size.height,
          ),
          hp,
        );
      }
    }

    // 2. Committed ink.
    for (final stroke in strokes) {
      final path =
          buildStrokePath(stroke, size, isComplete: true, thinning: thinning);
      if (path.getBounds().isEmpty) continue;
      canvas.drawPath(
        path,
        Paint()
          ..color = Color(stroke.color)
          ..style = PaintingStyle.fill
          ..isAntiAlias = true,
      );
    }

    // 3. Live stroke — read from the notifier at paint time, only for this page.
    final live = liveStrokeVN.value;
    if (live != null && live.page == pageIndex && live.stroke.points.isNotEmpty) {
      final path = buildStrokePath(live.stroke, size,
          isComplete: false, thinning: thinning);
      if (!path.getBounds().isEmpty) {
        canvas.drawPath(
          path,
          Paint()
            ..color = Color(live.stroke.color)
            ..style = PaintingStyle.fill
            ..isAntiAlias = true,
        );
      }
    }

    // 4. SELECT bounding box around the selected stroke (over everything).
    final si = selectedIndex;
    if (si != null && si >= 0 && si < strokes.length) {
      final b = penStrokeBounds(strokes[si]);
      if (b != null) {
        const padPx = 6.0;
        final rect = Rect.fromLTRB(
          b.left * size.width - padPx,
          b.top * size.height - padPx,
          b.right * size.width + padPx,
          b.bottom * size.height + padPx,
        );
        final rr = RRect.fromRectAndRadius(rect, const Radius.circular(4));
        canvas.drawRRect(
          rr,
          Paint()
            ..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
            ..style = PaintingStyle.fill,
        );
        canvas.drawRRect(
          rr,
          Paint()
            ..color = const Color(0xFF2962FF)
            ..style = PaintingStyle.stroke
            ..strokeWidth = 1.5
            ..isAntiAlias = true,
        );
      }
    }
  }

  @override
  bool shouldRepaint(_PageOverlayPainter old) =>
      !identical(old.liveStrokeVN, liveStrokeVN) ||
      old.pageIndex != pageIndex ||
      !identical(old.strokes, strokes) ||
      old.strokes.length != strokes.length ||
      !identical(old.highlights, highlights) ||
      old.highlights.length != highlights.length ||
      old.selectedIndex != selectedIndex ||
      old.pageSize != pageSize ||
      old.thinning != thinning;
}

/// Test-only handle on the live-stroke painter wiring (Bug 1 regression guard).
///
/// The live stroke must be read from a [ValueListenable] at PAINT time, so a
/// mid-stroke update repaints WITHOUT a rebuild of `pageOverlaysBuilder`. This
/// seam lets a widget test pump the real painter and assert that pushing a new
/// value into the notifier triggers a repaint (which the old build-time-snapshot
/// design did not).
@visibleForTesting
class LiveStrokeOverlayHarness {
  LiveStrokeOverlayHarness({required this.pageIndex});

  final int pageIndex;
  final ValueNotifier<int> overlayRepaint = ValueNotifier<int>(0);
  final ValueNotifier<_LiveStrokeData?> _liveStrokeVN = ValueNotifier(null);

  /// Push a live stroke for [page] (or null to clear). Mirrors what
  /// `_updateLiveStroke`/`_endStroke` do at runtime.
  void setLiveStroke(int page, PenStroke? stroke) {
    _liveStrokeVN.value = stroke == null ? null : _LiveStrokeData(page, stroke);
  }

  /// The real [CustomPainter] used by the editor, wired to this harness's
  /// notifiers exactly as `pageOverlaysBuilder` wires it.
  CustomPainter buildPainter() => _PageOverlayPainter(
        overlayRepaint: overlayRepaint,
        liveStrokeVN: _liveStrokeVN,
        pageIndex: pageIndex,
        strokes: const [],
        highlights: const [],
        pageSize: const Size(100, 100),
        thinning: kDefaultPenThinning,
      );

  void dispose() {
    overlayRepaint.dispose();
    _liveStrokeVN.dispose();
  }
}

/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
/// Tap opens the anchor's split view; long-press deletes the anchor.
class _ScratchLinkMarker extends StatelessWidget {
  const _ScratchLinkMarker({required this.onTap, required this.onLongPress});

  final VoidCallback onTap;
  final VoidCallback onLongPress;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: onTap,
      onLongPress: onLongPress,
      child: Material(
        color: cs.tertiaryContainer,
        elevation: 2,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(8),
          side: BorderSide(color: cs.outlineVariant),
        ),
        child: Icon(
          Icons.sticky_note_2,
          size: 20,
          color: cs.onTertiaryContainer,
        ),
      ),
    );
  }
}
