// lib/storage/badnote_sidecar.dart
//
// The on-disk sidecar model: all annotations for one source file, serialized as
// `<file>.badnote.json` next to the file ("跟着文件走"). This is Phase 1 of the
// file-based storage plan (docs/plans/2026-06-24-file-based-storage.md §A) — a
// pure model with NO runtime wiring yet.
//
// Design rule: REUSE the existing JSON shapes verbatim; do not invent a parallel
// stroke format. Specifically:
//   * per-page ink     → List<EditorStroke> (lib/editor/engine/stroke_model.dart;
//                         byte-for-byte the `ink.stroke_json` column today)
//   * scratchpad ink   → List<InkStroke>    (lib/models/ink_stroke.dart; the exact
//                         format scratchpads already persist, absolute world px)
//   * scratch anchors  → ScratchLink        (lib/models/scratch_link.dart)
//   * bookmarks        → Bookmark           (lib/models/bookmark.dart)
//
// Only the *containers* and the (previously in-memory-only) highlight rect are
// new here. Unknown JSON fields are ignored on read so the schema is
// forward-compatible (e.g. a future `brush` field — see §A.5 brush TODO).

import 'dart:ui' show Rect;

import '../editor/engine/stroke_model.dart';
import '../models/bookmark.dart';
import '../models/ink_stroke.dart';
import '../models/scratch_link.dart';

/// Current sidecar schema version. Persisted as `badnoteSidecarVersion` for
/// forward-compat; readers tolerate unknown extra fields.
const int kBadnoteSidecarVersion = 1;

/// A single highlighted text rectangle on a page, normalized to the page rect
/// ([0,1] for l/t/r/b — exactly as `_highlightSelection` computes it in
/// pen_editor_screen.dart) plus an ARGB [color]. There is no existing highlight
/// MODEL in the codebase (highlights are in-memory `Rect`s today, see
/// `TODO(persist-highlights)`), so this small value class is the representation.
class SidecarHighlight {
  const SidecarHighlight({
    required this.l,
    required this.t,
    required this.r,
    required this.b,
    this.color = 0xFFFFFF00,
  });

  /// Normalized left edge in [0,1].
  final double l;

  /// Normalized top edge in [0,1].
  final double t;

  /// Normalized right edge in [0,1].
  final double r;

  /// Normalized bottom edge in [0,1].
  final double b;

  /// ARGB color of the highlight.
  final int color;

  /// Builds a highlight from a normalized [Rect] (as stored in
  /// `_highlightsByPage`) and an ARGB color.
  factory SidecarHighlight.fromRect(Rect rect, {int color = 0xFFFFFF00}) =>
      SidecarHighlight(
        l: rect.left,
        t: rect.top,
        r: rect.right,
        b: rect.bottom,
        color: color,
      );

  /// The normalized rect (page-relative) for rendering.
  Rect toRect() => Rect.fromLTRB(l, t, r, b);

  Map<String, dynamic> toJson() => {
        'l': l,
        't': t,
        'r': r,
        'b': b,
        'color': color,
      };

  factory SidecarHighlight.fromJson(Map<String, dynamic> json) =>
      SidecarHighlight(
        l: (json['l'] as num).toDouble(),
        t: (json['t'] as num).toDouble(),
        r: (json['r'] as num).toDouble(),
        b: (json['b'] as num).toDouble(),
        color: (json['color'] as num?)?.toInt() ?? 0xFFFFFF00,
      );

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is SidecarHighlight &&
          runtimeType == other.runtimeType &&
          l == other.l &&
          t == other.t &&
          r == other.r &&
          b == other.b &&
          color == other.color;

  @override
  int get hashCode => Object.hash(l, t, r, b, color);

  @override
  String toString() =>
      'SidecarHighlight(l: $l, t: $t, r: $r, b: $b, color: $color)';
}

/// A single typed-text annotation on a page (PDF editor for now). Position is
/// NORMALIZED to the page rect ([nx],[ny] in [0,1]) so the box stays glued under
/// zoom/scroll, exactly like [SidecarHighlight] / [ScratchLink]. [fontSize] is
/// PAGE-RELATIVE (a fraction of the page width), so the rendered text scales
/// with the page; the editor multiplies it by the on-screen page width.
class SidecarText {
  const SidecarText({
    required this.id,
    required this.nx,
    required this.ny,
    required this.text,
    this.fontSize = 0.03,
    this.color = 0xFF000000,
    this.fontWeight = 400,
    this.fontFamily,
  });

  /// Stable id (uuid) so edits/deletes address a specific box.
  final String id;

  /// Normalized x of the box's top-left in [0,1].
  final double nx;

  /// Normalized y of the box's top-left in [0,1].
  final double ny;

  /// The typed text.
  final String text;

  /// Font size as a fraction of page WIDTH (page-relative; scales with zoom).
  final double fontSize;

  /// ARGB text color.
  final int color;

  /// CSS-like numeric weight (100–900). Default 400 (regular).
  final int fontWeight;

  /// Optional family name. Null → editor default (IBM Plex Sans).
  final String? fontFamily;

  SidecarText copyWith({
    String? id,
    double? nx,
    double? ny,
    String? text,
    double? fontSize,
    int? color,
    int? fontWeight,
    String? fontFamily,
    bool clearFontFamily = false,
  }) =>
      SidecarText(
        id: id ?? this.id,
        nx: nx ?? this.nx,
        ny: ny ?? this.ny,
        text: text ?? this.text,
        fontSize: fontSize ?? this.fontSize,
        color: color ?? this.color,
        fontWeight: fontWeight ?? this.fontWeight,
        fontFamily:
            clearFontFamily ? null : (fontFamily ?? this.fontFamily),
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'nx': nx,
        'ny': ny,
        'text': text,
        'fontSize': fontSize,
        'color': color,
        'fontWeight': fontWeight,
        if (fontFamily != null) 'fontFamily': fontFamily,
      };

  factory SidecarText.fromJson(Map<String, dynamic> json) => SidecarText(
        id: json['id'] as String,
        nx: (json['nx'] as num).toDouble(),
        ny: (json['ny'] as num).toDouble(),
        text: (json['text'] as String?) ?? '',
        fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03,
        color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
        fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400,
        fontFamily: json['fontFamily'] as String?,
      );

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is SidecarText &&
          runtimeType == other.runtimeType &&
          id == other.id &&
          nx == other.nx &&
          ny == other.ny &&
          text == other.text &&
          fontSize == other.fontSize &&
          color == other.color &&
          fontWeight == other.fontWeight &&
          fontFamily == other.fontFamily;

  @override
  int get hashCode => Object.hash(
        id,
        nx,
        ny,
        text,
        fontSize,
        color,
        fontWeight,
        fontFamily,
      );

  @override
  String toString() =>
      'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, '
      'fontSize: $fontSize, weight: $fontWeight, family: $fontFamily)';
}

/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
/// world pixels (unchanged format from `SplitViewScreen`), plus the world size
/// so it restores (today the canvas always resets to 4000×4000).
class SidecarScratchpad {
  const SidecarScratchpad({
    this.canvasWidth = 4000.0,
    this.canvasHeight = 4000.0,
    this.strokes = const [],
  });

  final double canvasWidth;
  final double canvasHeight;

  /// Absolute-world-pixel strokes, in `InkStroke.toJson()` format.
  final List<InkStroke> strokes;

  Map<String, dynamic> toJson() => {
        'canvasWidth': canvasWidth,
        'canvasHeight': canvasHeight,
        'strokes': strokes.map((s) => s.toJson()).toList(),
      };

  factory SidecarScratchpad.fromJson(Map<String, dynamic> json) =>
      SidecarScratchpad(
        canvasWidth: (json['canvasWidth'] as num?)?.toDouble() ?? 4000.0,
        canvasHeight: (json['canvasHeight'] as num?)?.toDouble() ?? 4000.0,
        strokes: ((json['strokes'] as List<dynamic>?) ?? const [])
            .map((e) => InkStroke.fromJson(e as Map<String, dynamic>))
            .toList(),
      );

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is SidecarScratchpad &&
          runtimeType == other.runtimeType &&
          canvasWidth == other.canvasWidth &&
          canvasHeight == other.canvasHeight &&
          _listEq(strokes, other.strokes);

  @override
  int get hashCode =>
      Object.hash(canvasWidth, canvasHeight, Object.hashAll(strokes));

  @override
  String toString() => 'SidecarScratchpad(canvasWidth: $canvasWidth, '
      'canvasHeight: $canvasHeight, strokes: ${strokes.length})';
}

/// A scratch link anchor that EMBEDS its private scratchpad (merges today's two
/// SQLite tables — `scratch_links` geometry + `scratchpads` ink — see §A.2).
class SidecarScratchLink {
  const SidecarScratchLink({
    required this.link,
    this.scratchpad = const SidecarScratchpad(),
  });

  /// Anchor geometry (reuses [ScratchLink] verbatim).
  final ScratchLink link;

  /// The anchor's private scratchpad.
  final SidecarScratchpad scratchpad;

  Map<String, dynamic> toJson() => {
        ...link.toJson(),
        'scratchpad': scratchpad.toJson(),
      };

  factory SidecarScratchLink.fromJson(Map<String, dynamic> json) =>
      SidecarScratchLink(
        link: ScratchLink.fromJson(json),
        scratchpad: json['scratchpad'] == null
            ? const SidecarScratchpad()
            : SidecarScratchpad.fromJson(
                json['scratchpad'] as Map<String, dynamic>),
      );

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is SidecarScratchLink &&
          runtimeType == other.runtimeType &&
          link == other.link &&
          scratchpad == other.scratchpad;

  @override
  int get hashCode => Object.hash(link, scratchpad);

  @override
  String toString() =>
      'SidecarScratchLink(link: $link, scratchpad: $scratchpad)';
}

/// The whole sidecar: all annotations for one source file.
///
/// Maps to the JSON in §A.2 of the plan. `strokes` and `highlights` are keyed by
/// page index. Strokes reuse [EditorStroke] JSON; bookmarks reuse [Bookmark]
/// JSON; scratch links reuse [ScratchLink] JSON (embedding [InkStroke] JSON for
/// the scratchpad).
class BadnoteSidecar {
  BadnoteSidecar({
    this.version = kBadnoteSidecarVersion,
    this.sourceFile,
    this.docType,
    this.title,
    this.pageCount,
    this.rotation = 0,
    this.createdAt,
    this.updatedAt,
    Map<int, List<EditorStroke>>? strokes,
    Map<int, List<SidecarHighlight>>? highlights,
    Map<int, List<SidecarText>>? texts,
    List<Bookmark>? bookmarks,
    List<SidecarScratchLink>? scratchLinks,
    Map<int, String>? legacyAnnotations,
    this.ocrText,
    this.pageText,
    this.legacyId,
    this.background,
  })  : strokes = strokes ?? <int, List<EditorStroke>>{},
        highlights = highlights ?? <int, List<SidecarHighlight>>{},
        texts = texts ?? <int, List<SidecarText>>{},
        bookmarks = bookmarks ?? <Bookmark>[],
        scratchLinks = scratchLinks ?? <SidecarScratchLink>[],
        legacyAnnotations = legacyAnnotations ?? <int, String>{};

  /// Schema version (`badnoteSidecarVersion`).
  final int version;

  /// Basename of the annotated source file, e.g. `Calculus Lecture 3.pdf`.
  final String? sourceFile;

  /// `pdf` / `pptx` / `notebook` etc.
  final String? docType;

  /// Display title for a standalone (non-file-backed) notebook (`docType ==
  /// 'notebook'`). Null for file-backed sidecars, whose title is the filename.
  final String? title;

  final int? pageCount;
  final int rotation;
  final DateTime? createdAt;
  final DateTime? updatedAt;

  /// Page index → committed [EditorStroke]s (normalized page coords).
  final Map<int, List<EditorStroke>> strokes;

  /// Page index → highlighted text rects (normalized).
  final Map<int, List<SidecarHighlight>> highlights;

  /// Page index → typed-text annotations (normalized position, page-relative
  /// font size). PDF editor only for now (note text is a later increment).
  final Map<int, List<SidecarText>> texts;

  final List<Bookmark> bookmarks;
  final List<SidecarScratchLink> scratchLinks;

  /// Raw legacy per-page `annotation_json` blobs preserved verbatim from the
  /// DEAD pre-editor `annotations` SQLite table (keyed by page number). Populated
  /// only by the one-time SQLite→sidecar migration so no legacy data is silently
  /// dropped; the live editor ignores it. Empty for all freshly authored
  /// sidecars.
  final Map<int, String> legacyAnnotations;

  /// Searchable text recovered from this notebook's handwriting via local OCR
  /// (Phase 6 search index). Persisted in the sidecar — the source of truth —
  /// so the vault-scan search index can find handwritten notes WITHOUT the
  /// (rebuildable, per-device) SQLite cache. Typed text already lives in the
  /// strokes' `textContent`, so this holds ONLY the OCR'd handwriting. Null when
  /// the notebook has no handwriting or OCR hasn't run.
  final String? ocrText;

  /// Searchable text of the underlying DOCUMENT BODY for a file-backed notebook
  /// (a PDF), captured ONCE at import time so the vault-scan search index covers
  /// the document — not just the user's annotations. It is either the PDF's
  /// embedded (printed) text layer, or — for a RASTERIZED / scanned PDF with no
  /// text layer — the result of a background OCR pass over the rendered pages.
  /// Pages are joined with `\f` (form feed) but the index treats it as a flat
  /// blob. Null when the document has not been indexed yet (back-compat: an old
  /// sidecar simply omits the field) or has no extractable/recognized text. This
  /// is distinct from [ocrText], which holds ONLY handwriting OCR.
  final String? pageText;

  /// The legacy SQLite row id this sidecar was migrated from (a `documents.id`
  /// or `notes.id`). Set ONLY by the one-time migration; it makes the migration
  /// idempotent (a re-run recognizes an already-migrated item by this id even if
  /// its folder name collided). Null for all freshly authored sidecars.
  final String? legacyId;

  /// The page-background template for a standalone notebook, stored as the
  /// [NoteBackground] enum `name` (e.g. `dots`, `cornell`). Kept as a raw String
  /// here so the storage model stays UI-decoupled; the editor decodes it via
  /// `noteBackgroundFromName` (missing/unknown → blank, back-compat).
  final String? background;

  Map<String, dynamic> toJson() => {
        'badnoteSidecarVersion': version,
        if (sourceFile != null) 'sourceFile': sourceFile,
        if (docType != null) 'docType': docType,
        if (title != null) 'title': title,
        if (pageCount != null) 'pageCount': pageCount,
        'rotation': rotation,
        if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
        if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
        'strokes': {
          for (final entry in strokes.entries)
            entry.key.toString():
                entry.value.map((s) => s.toJson()).toList(),
        },
        'highlights': {
          for (final entry in highlights.entries)
            entry.key.toString():
                entry.value.map((h) => h.toJson()).toList(),
        },
        if (texts.isNotEmpty)
          'texts': {
            for (final entry in texts.entries)
              entry.key.toString():
                  entry.value.map((t) => t.toJson()).toList(),
          },
        'bookmarks': bookmarks.map((b) => b.toJson()).toList(),
        'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(),
        if (legacyAnnotations.isNotEmpty)
          'legacyAnnotations': {
            for (final entry in legacyAnnotations.entries)
              entry.key.toString(): entry.value,
          },
        if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
        if (pageText != null && pageText!.isNotEmpty) 'pageText': pageText,
        if (legacyId != null) 'legacyId': legacyId,
        if (background != null) 'background': background,
      };

  factory BadnoteSidecar.fromJson(Map<String, dynamic> json) {
    Map<int, List<T>> decodePageMap<T>(
      Object? raw,
      T Function(Map<String, dynamic>) item,
    ) {
      final out = <int, List<T>>{};
      if (raw is Map) {
        raw.forEach((key, value) {
          final pageIndex = int.tryParse(key.toString());
          if (pageIndex == null || value is! List) return;
          out[pageIndex] = value
              .map((e) => item(e as Map<String, dynamic>))
              .toList();
        });
      }
      return out;
    }

    return BadnoteSidecar(
      version: (json['badnoteSidecarVersion'] as num?)?.toInt() ??
          kBadnoteSidecarVersion,
      sourceFile: json['sourceFile'] as String?,
      docType: json['docType'] as String?,
      title: json['title'] as String?,
      pageCount: (json['pageCount'] as num?)?.toInt(),
      rotation: (json['rotation'] as num?)?.toInt() ?? 0,
      createdAt: json['createdAt'] == null
          ? null
          : DateTime.tryParse(json['createdAt'] as String),
      updatedAt: json['updatedAt'] == null
          ? null
          : DateTime.tryParse(json['updatedAt'] as String),
      strokes: decodePageMap(json['strokes'], EditorStroke.fromJson),
      highlights: decodePageMap(json['highlights'], SidecarHighlight.fromJson),
      texts: decodePageMap(json['texts'], SidecarText.fromJson),
      bookmarks: ((json['bookmarks'] as List<dynamic>?) ?? const [])
          .map((e) => Bookmark.fromJson(e as Map<String, dynamic>))
          .toList(),
      scratchLinks: ((json['scratchLinks'] as List<dynamic>?) ?? const [])
          .map((e) => SidecarScratchLink.fromJson(e as Map<String, dynamic>))
          .toList(),
      legacyAnnotations: () {
        final raw = json['legacyAnnotations'];
        final out = <int, String>{};
        if (raw is Map) {
          raw.forEach((key, value) {
            final page = int.tryParse(key.toString());
            if (page != null && value is String) out[page] = value;
          });
        }
        return out;
      }(),
      ocrText: json['ocrText'] as String?,
      pageText: json['pageText'] as String?,
      legacyId: json['legacyId'] as String?,
      background: json['background'] as String?,
    );
  }
}

bool _listEq<T>(List<T> a, List<T> b) {
  if (identical(a, b)) return true;
  if (a.length != b.length) return false;
  for (var i = 0; i < a.length; i++) {
    if (a[i] != b[i]) return false;
  }
  return true;
}
