// lib/editor/persistence/sidecar_repository.dart
//
// Phase 2 of the file-based storage plan (docs/plans/2026-06-24-file-based-
// storage.md §F): the PDF editor's persistence sink. Replaces the SQLite-backed
// EditorRepository/SaveScheduler/DatabaseService trio for the pen editor with a
// single per-file SIDECAR (`<sourceFile>.badnote.json`) living ALONGSIDE the
// source file, so annotations travel with the file ("跟着文件走").
//
// Design:
//   * The repository owns the canonical in-memory [BadnoteSidecar]. Callers
//     mutate it through the schedule* methods, which (1) update the in-memory
//     model SYNCHRONOUSLY (so the snapshot can't be corrupted by a later edit
//     mid-write — the SaveScheduler discipline, §F.2) and (2) arm a single
//     debounce timer that writes the WHOLE sidecar atomically (§F.1, via
//     SidecarStore.writeAtomic — temp + rename + .bak).
//   * The unit of debounce is the whole document sidecar (sidecars are small —
//     sparse normalized strokes), one atomic write per debounce window.
//   * Identity is the SOURCE FILE PATH, not the old djb2 path-hash document id.
//     The sidecar IS the identity.

import 'dart:async';
import 'dart:io';

import '../../models/bookmark.dart';
import '../../models/scratch_link.dart';
import '../../storage/badnote_sidecar.dart';
import '../../storage/sidecar_store.dart';
import '../engine/stroke_model.dart';

/// Suffix appended to a source-file path to form its sidecar path.
const String kSidecarSuffix = '.badnote.json';

/// Process-wide registry of OPEN [SidecarRepository] instances (Phase 6 / §F.3).
///
/// The 800 ms debounce timer only protects against losing work to a crash that
/// happens *between* edits; it does NOT help when the OS suspends or kills the
/// app mid-window (the main data-loss window on a Windows tablet). The app's
/// lifecycle observer ([SidecarFlushObserver]) calls [flushAll] on
/// `paused`/`inactive`/`detached` to drain every open repo's pending write
/// before the process can be frozen.
///
/// A repo registers itself in [open] and removes itself in [dispose], so the
/// set always reflects exactly the editors holding unsaved sidecar state.
class SidecarRepositoryRegistry {
  SidecarRepositoryRegistry._();

  static final Set<SidecarRepository> _open = <SidecarRepository>{};

  /// The currently open repositories (for tests / inspection).
  static Set<SidecarRepository> get open => Set.unmodifiable(_open);

  /// Flush every open repository's pending debounced write and await them all.
  /// Safe to call repeatedly; a repo with nothing pending is a cheap no-op.
  static Future<void> flushAll() async {
    // Snapshot first: a flush may complete and (in a future) trigger disposal,
    // which mutates `_open` — iterating a copy avoids concurrent-modification.
    final repos = List<SidecarRepository>.of(_open);
    await Future.wait(repos.map((r) => r.flush()));
  }

  /// The open repository for [sourceFilePath], or null if none is open. Lets a
  /// background task (e.g. OCR) write through the SAME in-memory sidecar the
  /// editor holds, instead of racing it with a second open handle.
  static SidecarRepository? forPath(String sourceFilePath) {
    for (final r in _open) {
      if (r.sourceFilePath == sourceFilePath) return r;
    }
    return null;
  }

  static void _register(SidecarRepository repo) => _open.add(repo);

  static void _unregister(SidecarRepository repo) => _open.remove(repo);

  /// Test-only: drop all registrations so one test can't leak repos into the
  /// next. Does NOT flush or dispose them.
  static void resetForTest() => _open.clear();
}

/// Per-file persistence for the pen editor. Loads the sidecar for a source file
/// path, holds it in memory, and debounces atomic writes back to disk.
class SidecarRepository {
  SidecarRepository._({
    required this.sourceFilePath,
    required BadnoteSidecar sidecar,
    Duration debounce = const Duration(milliseconds: 800),
  })  : _sidecar = sidecar,
        _debounce = debounce;

  /// Absolute path to the annotated source file (e.g. the vault PDF copy).
  final String sourceFilePath;

  /// The sidecar file: `<sourceFilePath>.badnote.json`.
  File get sidecarFile => File('$sourceFilePath$kSidecarSuffix');

  final Duration _debounce;

  BadnoteSidecar _sidecar;
  Timer? _timer;
  bool _disposed = false;

  /// How many editors currently hold this repo. [open] reuses an existing
  /// instance and bumps the count; [dispose] only tears down at zero so a
  /// split-view / sticky overlay cannot clobber the PDF editor's sidecar.
  int _retainCount = 1;

  /// Tail of the in-flight write chain. Writes are serialized through this so a
  /// debounce-timer write and a concurrent lifecycle [flush] can't race on the
  /// same `.tmp`/rename (which would throw on the loser). Each write always
  /// persists the LATEST snapshot, so collapsing overlapping writes is safe.
  Future<void> _writeChain = Future<void>.value();

  /// Open (or create) the repository for [sourceFilePath]. Reads the existing
  /// sidecar if present (falling back to its `.bak`), else starts empty.
  ///
  /// Reuses an already-open repo for the same path (retain-counted) so a
  /// scratchpad overlay / split view cannot race the PDF editor with a second
  /// in-memory snapshot that would overwrite scratchpad ink on flush.
  static Future<SidecarRepository> open(
    String sourceFilePath, {
    String? docType,
    Duration debounce = const Duration(milliseconds: 800),
  }) async {
    final existing = SidecarRepositoryRegistry.forPath(sourceFilePath);
    if (existing != null && !existing._disposed) {
      existing._retainCount++;
      return existing;
    }
    final file = File('$sourceFilePath$kSidecarSuffix');
    final loaded = await SidecarStore.read(file);
    final sidecar = loaded ??
        BadnoteSidecar(
          sourceFile: _basename(sourceFilePath),
          docType: docType,
          createdAt: DateTime.now().toUtc(),
        );
    final repo = SidecarRepository._(
      sourceFilePath: sourceFilePath,
      sidecar: sidecar,
      debounce: debounce,
    );
    SidecarRepositoryRegistry._register(repo);
    return repo;
  }

  // ── Loaded snapshot accessors (read at open) ───────────────────────────────

  /// Page index → committed strokes loaded from the sidecar.
  Map<int, List<EditorStroke>> get loadedStrokes => _sidecar.strokes;

  /// Page index → highlight rects loaded from the sidecar.
  Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;

  /// Page index → typed-text annotations loaded from the sidecar.
  Map<int, List<SidecarText>> get loadedTexts => _sidecar.texts;

  /// Scratch-link anchors loaded from the sidecar.
  List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;

  /// Bookmarks loaded from the sidecar.
  List<Bookmark> get loadedBookmarks => _sidecar.bookmarks;

  /// The current in-memory sidecar (for tests / inspection).
  BadnoteSidecar get sidecar => _sidecar;

  /// The standalone-notebook title loaded from the sidecar, or null.
  String? get loadedTitle => _sidecar.title;

  /// The page-background template name loaded from the sidecar, or null
  /// (missing → blank, decoded by the editor).
  String? get loadedBackground => _sidecar.background;

  // ── Mutations (synchronous in-memory update + debounced atomic write) ──────

  /// Replace the standalone-notebook title and schedule a save. No-op if the
  /// title is unchanged.
  void scheduleTitleSave(String title) {
    if (_sidecar.title == title) return;
    _replace(title: title);
  }

  /// Replace the page-background template (a [NoteBackground] enum name) and
  /// schedule a save. No-op if unchanged.
  void scheduleBackgroundSave(String background) {
    if (_sidecar.background == background) return;
    _replace(background: background);
  }

  /// Replace the handwriting-OCR search text and schedule a save (Phase 6
  /// search index). No-op if unchanged.
  void scheduleOcrTextSave(String? ocrText) {
    final next = (ocrText != null && ocrText.isEmpty) ? null : ocrText;
    if (_sidecar.ocrText == next) return;
    _replace(ocrText: next, clearOcrText: next == null);
  }

  /// The OCR text loaded from the sidecar, or null.
  String? get loadedOcrText => _sidecar.ocrText;

  /// Replace the document-body search text (PDF embedded text layer, or
  /// background OCR of a rasterized PDF — see [PdfTextIndexer]) and schedule a
  /// save. No-op if unchanged. An empty string is normalized to null.
  void schedulePageTextSave(String? pageText) {
    final next = (pageText != null && pageText.isEmpty) ? null : pageText;
    if (_sidecar.pageText == next) return;
    _replace(pageText: next, clearPageText: next == null);
  }

  /// The document-body search text loaded from the sidecar, or null.
  String? get loadedPageText => _sidecar.pageText;

  /// Replace the committed strokes for [pageIndex] and schedule a save.
  void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
    final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
    if (strokes.isEmpty) {
      next.remove(pageIndex);
    } else {
      next[pageIndex] = List<EditorStroke>.of(strokes);
    }
    _replace(strokes: next);
  }

  /// Replace the highlight rects for [pageIndex] and schedule a save.
  void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
    final next = Map<int, List<SidecarHighlight>>.from(_sidecar.highlights);
    if (highlights.isEmpty) {
      next.remove(pageIndex);
    } else {
      next[pageIndex] = List<SidecarHighlight>.of(highlights);
    }
    _replace(highlights: next);
  }

  /// Replace the typed-text annotations for [pageIndex] and schedule a save.
  void scheduleTextsSave(int pageIndex, List<SidecarText> texts) {
    final next = Map<int, List<SidecarText>>.from(_sidecar.texts);
    if (texts.isEmpty) {
      next.remove(pageIndex);
    } else {
      next[pageIndex] = List<SidecarText>.of(texts);
    }
    _replace(texts: next);
  }

  /// Add (or update) a scratch-link anchor, preserving any existing scratchpad,
  /// and schedule a save.
  void scheduleScratchLinkUpsert(ScratchLink link) {
    final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
    final idx = next.indexWhere((s) => s.link.id == link.id);
    if (idx == -1) {
      next.add(SidecarScratchLink(link: link));
    } else {
      next[idx] = SidecarScratchLink(
        link: link,
        scratchpad: next[idx].scratchpad,
      );
    }
    _replace(scratchLinks: next);
  }

  /// Remove the scratch-link anchor (and its embedded scratchpad) by [linkId].
  void scheduleScratchLinkDelete(String linkId) {
    final next = _sidecar.scratchLinks
        .where((s) => s.link.id != linkId)
        .toList(growable: false);
    _replace(scratchLinks: List<SidecarScratchLink>.of(next));
  }

  /// Replace the embedded scratchpad of the anchor [linkId] and schedule a save.
  /// No-op if the anchor isn't present.
  void scheduleScratchpadSave(String linkId, SidecarScratchpad scratchpad) {
    final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
    final idx = next.indexWhere((s) => s.link.id == linkId);
    if (idx == -1) return;
    next[idx] = SidecarScratchLink(link: next[idx].link, scratchpad: scratchpad);
    _replace(scratchLinks: next);
  }

  /// Add (or update, by id) a bookmark and schedule a save.
  void scheduleBookmarkUpsert(Bookmark bookmark) {
    final next = List<Bookmark>.of(_sidecar.bookmarks);
    final idx = next.indexWhere((b) => b.id == bookmark.id);
    if (idx == -1) {
      next.add(bookmark);
    } else {
      next[idx] = bookmark;
    }
    _replace(bookmarks: next);
  }

  /// Remove the bookmark by [bookmarkId] and schedule a save.
  void scheduleBookmarkDelete(String bookmarkId) {
    final next =
        _sidecar.bookmarks.where((b) => b.id != bookmarkId).toList();
    _replace(bookmarks: next);
  }

  /// Replace the whole bookmark list and schedule a save.
  void scheduleBookmarksSave(List<Bookmark> bookmarks) {
    _replace(bookmarks: List<Bookmark>.of(bookmarks));
  }

  /// The embedded scratchpad for [linkId], or null if the anchor is unknown.
  SidecarScratchpad? scratchpadFor(String linkId) {
    for (final s in _sidecar.scratchLinks) {
      if (s.link.id == linkId) return s.scratchpad;
    }
    return null;
  }

  // ── Flush / dispose ────────────────────────────────────────────────────────

  /// Write any pending change immediately and wait for it (and any in-flight
  /// write) to land. If the debounce timer is still armed, fire one final write
  /// of the latest snapshot; otherwise just drain whatever write is in flight.
  Future<void> flush() async {
    if (_timer != null) {
      _timer!.cancel();
      _timer = null;
      await _write();
      return;
    }
    // No pending edit, but a fire-and-forget timer write may still be running:
    // await the chain so the bytes are on disk before we return.
    await _writeChain;
  }

  /// Cancel pending timers. Call [flush] first to persist pending writes.
  void dispose() {
    if (_disposed) return;
    if (_retainCount > 1) {
      _retainCount--;
      return;
    }
    _disposed = true;
    _timer?.cancel();
    _timer = null;
    SidecarRepositoryRegistry._unregister(this);
  }

  // ── Internal ───────────────────────────────────────────────────────────────

  /// Build a new sidecar (touching `updatedAt`) from the current one with the
  /// given fields replaced, then arm the debounce timer. Snapshot is captured
  /// synchronously here so a later edit can't corrupt an in-flight write.
  void _replace({
    String? title,
    Map<int, List<EditorStroke>>? strokes,
    Map<int, List<SidecarHighlight>>? highlights,
    Map<int, List<SidecarText>>? texts,
    List<Bookmark>? bookmarks,
    List<SidecarScratchLink>? scratchLinks,
    String? ocrText,
    bool clearOcrText = false,
    String? pageText,
    bool clearPageText = false,
    String? background,
  }) {
    if (_disposed) return;
    _sidecar = BadnoteSidecar(
      version: _sidecar.version,
      sourceFile: _sidecar.sourceFile,
      docType: _sidecar.docType,
      title: title ?? _sidecar.title,
      pageCount: _sidecar.pageCount,
      rotation: _sidecar.rotation,
      createdAt: _sidecar.createdAt,
      updatedAt: DateTime.now().toUtc(),
      strokes: strokes ?? _sidecar.strokes,
      highlights: highlights ?? _sidecar.highlights,
      texts: texts ?? _sidecar.texts,
      bookmarks: bookmarks ?? _sidecar.bookmarks,
      scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
      ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
      pageText: clearPageText ? null : (pageText ?? _sidecar.pageText),
      background: background ?? _sidecar.background,
    );
    _timer?.cancel();
    _timer = Timer(_debounce, () {
      _timer = null;
      // Fire-and-forget; the next schedule simply re-arms the timer and the
      // atomic write guarantees no torn file.
      _write();
    });
  }

  /// Serialize writes through [_writeChain] so overlapping flushes never race
  /// on the temp file. Each link writes the latest in-memory snapshot at the
  /// moment it runs; an error in one write doesn't break the chain for the next.
  Future<void> _write() {
    final next = _writeChain.then((_) async {
      final snapshot = _sidecar;
      await SidecarStore.writeAtomic(sidecarFile, snapshot);
    });
    // Keep the chain alive past a failed write (e.g. transient FS error).
    _writeChain = next.catchError((_) {});
    return next;
  }

  static String _basename(String path) {
    final norm = path.replaceAll('\\', '/');
    final i = norm.lastIndexOf('/');
    return i == -1 ? norm : norm.substring(i + 1);
  }
}
