// lib/editor/canvas/pen_canvas.dart
//
// Pen-first canvas: ONE shared transform (an InteractiveViewer driven by a
// TransformationController we own) zooms/pans BOTH the PDF page bitmap and the
// ink layer together. A Listener wrapped around the InteractiveViewer reads raw
// pointer kind + pressure and tracks the active pointer COUNT to arbitrate
// draw vs pan/zoom — we own the gesture pipeline, pdfrx never sees gestures.
//
// Gesture arbitration (reimplemented clean-room from Saber's documented model):
//  - A draw gesture is exactly ONE active pointer that is a stylus / inverted
//    stylus, OR (when the user's finger-drawing toggle is on) a single finger.
//  - >= 2 active pointers ALWAYS means pan/zoom (pinch); never draw. If a 2nd
//    pointer lands while a stroke is in progress, that stroke is discarded
//    (accidental palm/finger).
//  - Palm rejection: once any stylus event is seen in a session, finger-drawing
//    is forced OFF so a resting palm/finger pans instead of marking.
//  - While a stroke is active, the InteractiveViewer's pan is disabled so it
//    can't fight the stroke; pinch-zoom still works because a 2nd pointer
//    cancels the stroke first, re-enabling pan/zoom.

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
import '../render/static_ink_painter.dart' as render;
import 'ink_painters.dart' show EraserPreviewPainter;
import 'pen_interactive_viewer.dart';
import 'pen_stroke.dart';

/// The active tool on the pen canvas.
enum CanvasTool { pen, highlighter, eraser }

class PenCanvas extends StatefulWidget {
  const PenCanvas({
    super.key,
    required this.pageWidget,
    required this.pageSize,
    required this.strokes,
    required this.transformationController,
    required this.tool,
    required this.color,
    required this.strokeWidth,
    required this.onStrokeComplete,
    required this.onEraseStroke,
    this.allowFingerDrawing = false,
    this.minScale = 0.5,
    this.maxScale = 8.0,
    this.onPenDebug,
    this.thinning = kDefaultPenThinning,
    this.sideButtonAction = PenButtonAction.eraser,
    this.eraserEndAction = PenButtonAction.eraser,
    this.onPenButtonAction,
  });

  /// Debug hook: called with a readout of the latest pen event
  /// (kind / pressure / pressureMin / pressureMax) so we can see what Windows
  /// actually delivers. Null in release UI.
  final void Function(String readout)? onPenDebug;

  /// The rendered PDF page bitmap, already sized to [pageSize].
  final Widget pageWidget;

  /// On-screen size (at scale 1.0) of the page rectangle in logical pixels.
  /// Ink normalized coords map onto this rectangle.
  final Size pageSize;

  /// Committed strokes for the CURRENT page (normalized coords).
  final List<PenStroke> strokes;

  /// Shared transform driving both page and ink.
  final TransformationController transformationController;

  final CanvasTool tool;
  final Color color;

  /// Pen width as a fraction of page width (so it zooms with the page).
  final double strokeWidth;

  /// Called with a finished stroke (normalized coords) to commit it.
  final void Function(PenStroke stroke) onStrokeComplete;

  /// Called to replace committed stroke [strokeIndex] with its surviving pieces
  /// after a partial (segment) erase. An empty [replacements] list removes the
  /// stroke entirely (whole-stroke erase).
  final void Function(int strokeIndex, List<PenStroke> replacements)
      onEraseStroke;

  /// User toggle: allow a single finger to draw. Forced off once a stylus is
  /// seen (palm rejection).
  final bool allowFingerDrawing;

  final double minScale;
  final double maxScale;

  /// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
  final double thinning;

  /// Configured action for the pen's side barrel button (W3 — resolved against
  /// the native pen plugin's flags on Windows).
  final PenButtonAction sideButtonAction;

  /// Configured action for the pen's eraser/inverted end (W3).
  final PenButtonAction eraserEndAction;

  /// Fired (edge-triggered) when a hardware pen button mapped to a non-eraser
  /// action (undo / toggleTool) is pressed.
  final void Function(PenButtonAction action)? onPenButtonAction;

  @override
  State<PenCanvas> createState() => _PenCanvasState();
}

class _PenCanvasState extends State<PenCanvas> {
  /// Active (down) pointers by id → their device kind. Size == pointer count.
  final Map<int, PointerDeviceKind> _activePointers = {};

  /// The pointer id currently driving a stroke, or null.
  int? _drawPointer;

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

  /// Live stroke snapshot handed to the LiveInkPainter; null when idle.
  PenStroke? _liveStroke;

  /// True when the active stylus reports the eraser signal (barrel button or
  /// inverted stylus), detected on hover/down.
  bool _eraserActive = false;

  /// Eraser preview cursor (normalized page coords), or null when not in eraser
  /// mode / the pen is not near the page. A ValueNotifier so the preview layer
  /// repaints on cursor moves WITHOUT rebuilding the whole canvas every frame
  /// (the old per-move setState was the eraser-lag source).
  final ValueNotifier<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(null);

  /// Committed ink mirrored as the canonical [EditorStroke] model, driving the
  /// revision-gated [render.StaticInkPainter] + [InkPictureCache] (P0 step 3).
  /// The cache replays a recorded ui.Picture for the committed layer, so pinch /
  /// pan / live-stroke frames never re-rasterize the committed ink — the
  /// P0.5 perf prerequisite. Kept in sync with [PenCanvas.strokes] (which the
  /// parent replaces with a fresh list identity on every commit/erase).
  final StrokeStore _store = StrokeStore();
  final InkPictureCache _inkCache = InkPictureCache();
  List<PenStroke>? _syncedStrokesRef;
  static const String _inkHostId = 'pen-canvas';

  /// Re-mirror [PenCanvas.strokes] into [_store] when the parent hands us a new
  /// list (identity change ⇒ a commit/erase happened). Bumping the store
  /// revision invalidates the cached Picture so the committed layer repaints.
  void _syncStore() {
    if (identical(_syncedStrokesRef, widget.strokes)) return;
    _syncedStrokesRef = widget.strokes;
    _store.replaceAll(
      widget.strokes.map((s) => EditorStroke.fromPenStroke(s)).toList(),
    );
  }

  /// True when the eraser would act (eraser tool selected, or a barrel/inverted
  /// eraser signal is live).
  bool get _isEraserMode =>
      widget.tool == CanvasTool.eraser || _eraserActive;

  /// Eraser radius as a fraction of page width (shared by the live erase and the
  /// preview overlay so they always agree). A decisive fixed size — the old
  /// strokeWidth*2 was so small that a pass removed only a couple of points and
  /// the stroke visibly survived ("选中了的笔画也不见得能删掉").
  static const double _eraserRadius = 0.02;

  /// Page aspect (height / width) so the eraser circle stays round on screen.
  double get _pageAspect => widget.pageSize.width <= 0
      ? 1.0
      : widget.pageSize.height / widget.pageSize.width;

  // The explicit user toggle wins: if finger-drawing is ON, a single finger
  // draws even after a stylus has been seen. (Palm rejection when the toggle is
  // OFF is automatic — fingers simply never draw — and a 2nd pointer always
  // cancels an in-progress stroke regardless.)
  bool get _fingerDrawingEnabled => widget.allowFingerDrawing;

  bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);

  /// Normalize stylus pressure to [0,1], or null when the device reports no
  /// usable pressure range (then perfect_freehand simulates pressure).
  double? _normalizedPressure(PointerEvent event) {
    if (!_isStylus(event.kind)) return null;
    final range = event.pressureMax - event.pressureMin;
    if (range > 0.0001) {
      return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
    }
    // No advertised range (some Windows pen stacks): use the raw normalized
    // pressure directly if it's a usable non-degenerate value, so we still get
    // real force instead of falling back to velocity simulation.
    if (event.pressure > 0.0 && event.pressure < 1.0) {
      return event.pressure;
    }
    return null;
  }

  /// The eraser signal. Two sources, ORed:
  ///  1. Flutter-native: secondary button held or an inverted stylus (works on
  ///     desktop / platforms that surface these).
  ///  2. Windows native pen plugin: barrel / inverted / eraser flags that
  ///     Flutter 3.44 drops, mapped through the configured side-button /
  ///     eraser-end actions (W3). Level-triggered, so holding the button keeps
  ///     erasing — correct for an eraser.
  bool _isEraserSignal(PointerEvent event) {
    // BITMASK test, not equality: Flutter defines kPrimaryStylusButton == 0x02
    // == kSecondaryButton, and kStylusContact == 0x01. While the pen TIP is
    // down with the barrel pressed, event.buttons == 0x03, so `== kSecondaryButton`
    // (0x02) is false — the side button registered only on hover, never while
    // drawing. `& kSecondaryButton != 0` catches both.
    if ((event.buttons & kSecondaryButton) != 0 ||
        event.kind == PointerDeviceKind.invertedStylus) {
      return true;
    }
    final hw = PenInputService.instance;
    if (hw.isActive) {
      final s = hw.current;
      if ((s.inverted || s.eraser) &&
          widget.eraserEndAction == PenButtonAction.eraser) {
        return true;
      }
      if (s.barrel && widget.sideButtonAction == PenButtonAction.eraser) {
        return true;
      }
    }
    return false;
  }

  /// Resolve the currently-active configured action from the native pen flags
  /// (eraser-end takes precedence over the side button when both are set).
  PenButtonAction _activeHwAction() {
    final hw = PenInputService.instance;
    if (!hw.isActive) return PenButtonAction.none;
    final s = hw.current;
    if (s.inverted || s.eraser) return widget.eraserEndAction;
    if (s.barrel) return widget.sideButtonAction;
    return PenButtonAction.none;
  }

  /// Last hardware action seen, for rising-edge detection of undo/toggleTool.
  PenButtonAction _lastHwAction = PenButtonAction.none;

  /// Edge-triggered dispatch of non-eraser button actions (undo / toggleTool).
  /// Eraser is handled level-triggered by [_isEraserSignal]; pan suppresses
  /// drawing via [_shouldDraw].
  void _dispatchHwButtonActions() {
    final action = _activeHwAction();
    if (action == _lastHwAction) return;
    _lastHwAction = action;
    if (action == PenButtonAction.undo ||
        action == PenButtonAction.toggleTool) {
      widget.onPenButtonAction?.call(action);
    }
  }

  /// True while a hardware button mapped to `pan` is held (suppresses drawing
  /// so the InteractiveViewer pans instead).
  bool get _hwPanActive => _activeHwAction() == PenButtonAction.pan;

  /// Pen tilt magnitude (degrees) for a stylus event, or null when unavailable.
  double? _tiltFor(PointerEvent event) {
    if (!_isStylus(event.kind)) return null;
    final hw = PenInputService.instance;
    if (!hw.isActive) return null;
    final t = hw.current.tiltMagnitude;
    return t == 0 ? null : t;
  }

  /// Decide whether the gesture currently forming should DRAW. Delegates to the
  /// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
  /// the tests can never disagree on the rule.
  bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
        activePointerCount: _activePointers.length,
        kind: kind,
        fingerDrawingEnabled: _fingerDrawingEnabled,
        hwPanActive: _hwPanActive,
      );

  // --- Coordinate mapping ---------------------------------------------------

  /// Map a global pointer position into normalized page coords using the
  /// shared transform (inverse) and this widget's geometry.
  PenPoint? _toNormalized(Offset globalPosition, double? pressure,
      {double? tilt}) {
    final box = context.findRenderObject() as RenderBox?;
    if (box == null) return null;
    final local = box.globalToLocal(globalPosition);

    // Undo the InteractiveViewer transform to get scene (untransformed) coords.
    final scene = widget.transformationController.toScene(local);

    final nx = scene.dx / widget.pageSize.width;
    final ny = scene.dy / widget.pageSize.height;
    return PenPoint(nx, ny, pressure, tilt: tilt);
  }

  // --- Stroke lifecycle -----------------------------------------------------

  void _startStroke(PointerDownEvent event) {
    _drawPointer = event.pointer;
    _livePoints.clear();
    final p = _toNormalized(event.position, _normalizedPressure(event),
        tilt: _tiltFor(event));
    if (p != null) _livePoints.add(p);

    if (_eraserActive || widget.tool == CanvasTool.eraser) {
      _eraserCursor.value = p;
      _eraseAt(p);
      // No setState here: the preview repaints via the notifier, and any erased
      // stroke repaints via the editor's onEraseStroke setState. (_liveStroke is
      // already null in eraser mode.)
      if (_liveStroke != null) setState(() => _liveStroke = null);
      return;
    }
    _updateLiveStroke();
  }

  void _extendStroke(PointerMoveEvent event) {
    final p = _toNormalized(event.position, _normalizedPressure(event),
        tilt: _tiltFor(event));
    if (p == null) return;

    if (_eraserActive || widget.tool == CanvasTool.eraser) {
      _eraserCursor.value = p;
      _eraseAt(p);
      return;
    }
    _livePoints.add(p);
    _updateLiveStroke();
  }

  void _endStroke() {
    if (_drawPointer == null) return;
    final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser;
    if (!wasEraser && _livePoints.isNotEmpty) {
      widget.onStrokeComplete(
        PenStroke(
          points: List.of(_livePoints),
          color: _currentColor().toARGB32(),
          width: widget.strokeWidth,
          kind: _currentKind(),
        ),
      );
    }
    _drawPointer = null;
    _livePoints.clear();
    _eraserCursor.value = null; // hide the preview when the pen lifts
    setState(() => _liveStroke = null);
  }

  /// Discard the in-progress stroke without committing (palm/2nd-finger).
  void _cancelStroke() {
    _drawPointer = null;
    _livePoints.clear();
    _eraserCursor.value = null;
    setState(() => _liveStroke = null);
  }

  void _updateLiveStroke() {
    setState(() {
      _liveStroke = PenStroke(
        points: List.of(_livePoints),
        color: _currentColor().toARGB32(),
        width: widget.strokeWidth,
        kind: _currentKind(),
      );
    });
  }

  PenStrokeKind _currentKind() =>
      widget.tool == CanvasTool.highlighter
          ? PenStrokeKind.highlighter
          : PenStrokeKind.pen;

  Color _currentColor() => widget.tool == CanvasTool.highlighter
      ? widget.color.withAlpha(0x80)
      : widget.color;

  /// Partial (segment) erase: find the first committed stroke the eraser circle
  /// touches and replace it with its surviving pieces. The eraser radius is in
  /// normalized page-width fractions; [aspect] corrects the y axis so the circle
  /// stays round on screen (the page rect is not square).
  void _eraseAt(PenPoint? p) {
    if (p == null) return;
    final radius = _eraserRadius; // normalized (page-width fraction)
    final aspect = _pageAspect;
    for (var i = widget.strokes.length - 1; i >= 0; i--) {
      final stroke = widget.strokes[i];
      if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
      final pieces =
          splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
      // Defensive no-op guard (strokeHit already passed, so a hit is expected).
      if (pieces.length == 1 && identical(pieces.first, stroke)) return;
      widget.onEraseStroke(i, pieces);
      return;
    }
  }

  // --- Listener callbacks ---------------------------------------------------

  /// Highest NORMALIZED pressure seen since the diagnostic was last reset —
  /// makes "does pressure actually vary?" unambiguous on the readout.
  double _peakNorm = 0;

  void _emitPenDebug(PointerEvent event) {
    final cb = widget.onPenDebug;
    if (cb == null) return;
    final norm = _normalizedPressure(event);
    if (norm != null && norm > _peakNorm) _peakNorm = norm;
    cb('${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}');
  }

  void _onPointerHover(PointerHoverEvent event) {
    if (_isStylus(event.kind)) {
      _emitPenDebug(event);
      // Fire edge-triggered button actions (undo / toggleTool) on hover so a
      // mapped barrel press works without first touching down.
      _dispatchHwButtonActions();
      // Detect eraser (barrel button / inverted) while hovering.
      _eraserActive = _isEraserSignal(event);
    }
  }

  void _onPointerDown(PointerDownEvent event) {
    if (event.kind == PointerDeviceKind.trackpad) return;
    if (_isStylus(event.kind)) {
      _emitPenDebug(event);
      // Fire edge-triggered button actions for a direct pen-down (no prior
      // hover); the native observer latched this contact's flags before Flutter
      // synthesized this event (plan M1/M2).
      _dispatchHwButtonActions();
    }

    _activePointers[event.pointer] = event.kind;

    // A 2nd pointer arriving during a stroke = pinch/palm → cancel the stroke
    // and let the InteractiveViewer take over pan/zoom.
    if (_activePointers.length >= 2) {
      if (_drawPointer != null) _cancelStroke();
      return;
    }

    // Single pointer: decide draw vs pan. Eraser is on if this stylus down
    // signals it (barrel button / inverted), or hover already flagged it.
    if (_isStylus(event.kind)) {
      _eraserActive = _eraserActive || _isEraserSignal(event);
    } else {
      _eraserActive = false;
    }

    if (_shouldDraw(event.kind)) {
      _startStroke(event);
    }
  }

  void _onPointerMove(PointerMoveEvent event) {
    if (_isStylus(event.kind)) _emitPenDebug(event);
    if (event.pointer != _drawPointer) return;
    if (_activePointers.length >= 2) return; // pinch owns it
    _extendStroke(event);
  }

  void _onPointerUp(PointerUpEvent event) {
    final wasDrawer = event.pointer == _drawPointer;
    _activePointers.remove(event.pointer);
    if (wasDrawer) _endStroke();
  }

  void _onPointerCancel(PointerCancelEvent event) {
    final wasDrawer = event.pointer == _drawPointer;
    _activePointers.remove(event.pointer);
    if (wasDrawer) _cancelStroke();
  }

  @override
  void dispose() {
    _eraserCursor.dispose();
    _inkCache.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // The PEN never reaches PenInteractiveViewer's recognizer (it excludes
    // stylus), so a stylus stroke can never be stolen as a pan. panEnabled only
    // governs touch/mouse: suppress pan while a single-finger / mouse stroke is
    // in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
    // so a pinch re-enables pan/zoom immediately.
    final panEnabled = _drawPointer == null;

    // Mirror committed strokes into the revision-tracked store (only re-mirrors
    // when the parent handed us a new list identity).
    _syncStore();
    final liveEditorStroke =
        _liveStroke == null ? null : EditorStroke.fromPenStroke(_liveStroke!);

    return Listener(
      onPointerHover: _onPointerHover,
      onPointerDown: _onPointerDown,
      onPointerMove: _onPointerMove,
      onPointerUp: _onPointerUp,
      onPointerCancel: _onPointerCancel,
      child: PenInteractiveViewer(
        transformationController: widget.transformationController,
        minScale: widget.minScale,
        maxScale: widget.maxScale,
        panEnabled: panEnabled,
        scaleEnabled: true,
        child: SizedBox(
          width: widget.pageSize.width,
          height: widget.pageSize.height,
          child: Stack(
            children: [
              // PDF page bitmap. Wrapped in its own RepaintBoundary (W2) so the
              // per-move live-ink repaints and the static-ink repaints never
              // mark the page's raster layer dirty — isolating it from
              // ink-driven repaints. (The definitive crisp-on-zoom / no-flash
              // fix is the P0.5 page_tile DPI-on-settle double-buffer; this
              // boundary is the safe, non-regressive interim per plan M3.)
              Positioned.fill(
                child: RepaintBoundary(child: widget.pageWidget),
              ),
              // Committed ink (static layer, isolated repaint). Backed by the
              // revision-gated ui.Picture cache (P0 step 3): unchanged across
              // pinch/pan/live-move frames ⇒ cache hit ⇒ zero re-raster.
              Positioned.fill(
                child: RepaintBoundary(
                  child: CustomPaint(
                    painter: render.StaticInkPainter(
                      hostId: _inkHostId,
                      store: _store,
                      pageSize: widget.pageSize,
                      cache: _inkCache,
                      thinning: widget.thinning,
                    ),
                  ),
                ),
              ),
              // Eraser preview: faint outline on strokes about to be deleted +
              // the eraser circle. Mounted only in eraser mode with a cursor.
              if (_isEraserMode)
                Positioned.fill(
                  child: RepaintBoundary(
                    child: CustomPaint(
                      painter: EraserPreviewPainter(
                        strokes: widget.strokes,
                        cursor: _eraserCursor,
                        radius: _eraserRadius,
                        aspect: _pageAspect,
                        pageSize: widget.pageSize,
                      ),
                    ),
                  ),
                ),
              // Live ink (current stroke only, isolated repaint).
              Positioned.fill(
                child: RepaintBoundary(
                  child: CustomPaint(
                    painter: render.LiveInkPainter(
                      live: liveEditorStroke,
                      pageSize: widget.pageSize,
                      thinning: widget.thinning,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
