// lib/services/vault_search_index.dart
//
// Phase 6 of the file-based storage plan (docs/plans/2026-06-24-file-based-
// storage.md §B/§F): the search index, rebuilt by SCANNING the vault sidecars
// (the source of truth) rather than the demoted SQLite cache.
//
// What it indexes, per notebook/note folder, from its `*.badnote.json` sidecar:
//   * the title (standalone notebooks) / source filename (file-backed docs),
//   * every typed text box   — EditorStroke(tool: text).textContent across all
//                              pages,
//   * the handwriting OCR text persisted in the sidecar's `ocrText` field.
//
// It does NOT (yet) index a PDF's embedded text layer — that is a known gap (see
// the REPORT in the task / the class doc below). Matching uses the existing pure
// search primitives (normalize / rank / snippet), so CJK substring search works.

import 'dart:io';

import '../editor/search/search_ranking.dart';
import '../editor/search/search_snippet.dart';
import '../storage/badnote_sidecar.dart';
import '../storage/sidecar_store.dart';
import 'vault_service.dart';

/// One indexed notebook: where it lives and the text harvested from its sidecar.
class VaultSearchEntry {
  const VaultSearchEntry({
    required this.id,
    required this.title,
    required this.openPath,
    required this.docType,
    required this.text,
    required this.isNote,
  });

  /// Stable id (the notebook folder path).
  final String id;

  /// Display title (note title / source filename).
  final String title;

  /// Path to pass to the editor: the in-vault source file for docs, or the
  /// synthetic `<folder>/notebook` note path for standalone notebooks.
  final String openPath;

  /// `pdf` / `pptx` / `ppt` / `docx` / `notebook`.
  final String docType;

  /// All searchable text harvested from the sidecar (title + typed text + OCR),
  /// joined for substring matching.
  final String text;

  /// True for standalone (free-ink) notebooks, false for file-backed documents.
  final bool isNote;
}

/// A search hit over the vault: the entry plus a display snippet of the match.
class VaultSearchHit {
  const VaultSearchHit({required this.entry, required this.snippet});

  final VaultSearchEntry entry;
  final Snippet snippet;
}

/// Builds and queries a scan-based full-text index over the vault sidecars.
///
/// The index is the list of [VaultSearchEntry]s built by [rebuild]; query is a
/// pure substring/rank over their harvested text (CJK-safe). Cheap enough to
/// rebuild lazily on demand for a single-user vault; there is no background
/// thread and no persisted index file (the SQLite cache is no longer the search
/// source of truth).
///
/// HONEST SCOPE (what search covers / does NOT):
///   * COVERS: note/doc titles, typed text boxes, and handwriting OCR text that
///     has been persisted into a sidecar's `ocrText` field.
///   * DOES NOT cover: a PDF's embedded (printed) text layer — only the user's
///     annotations are indexed, not the underlying document body. Indexing the
///     PDF text layer would require rendering each page through pdfrx at scan
///     time; deferred. OCR is only present where it has already been run and
///     written back to the sidecar.
class VaultSearchIndex {
  VaultSearchIndex(this._vault);

  final VaultService _vault;

  List<VaultSearchEntry> _entries = const [];
  bool _built = false;

  /// The entries from the most recent [rebuild] (for tests / inspection).
  List<VaultSearchEntry> get entries => List.unmodifiable(_entries);

  /// Scan the vault and (re)build the in-memory index. Safe on an empty/missing
  /// vault (yields an empty index). Never throws on a single unreadable sidecar.
  Future<void> rebuild() async {
    final entries = <VaultSearchEntry>[];

    final notebooks = await _vault.scanNotebooks();
    for (final nb in notebooks) {
      final sidecar = await SidecarStore.read(
        File('${nb.sourceFilePath}$kVaultSidecarSuffix'),
      );
      entries.add(
        VaultSearchEntry(
          id: nb.folderPath,
          title: nb.filename,
          openPath: nb.sourceFilePath,
          docType: nb.docType,
          text: _harvest(title: nb.filename, sidecar: sidecar),
          isNote: false,
        ),
      );
    }

    final notes = await _vault.scanNotes();
    for (final note in notes) {
      final sidecar = await SidecarStore.read(
        File('${note.notePath}$kVaultSidecarSuffix'),
      );
      entries.add(
        VaultSearchEntry(
          id: note.folderPath,
          title: note.title,
          openPath: note.notePath,
          docType: 'notebook',
          text: _harvest(title: note.title, sidecar: sidecar),
          isNote: true,
        ),
      );
    }

    _entries = entries;
    _built = true;
  }

  /// Search the index for [query], rebuilding it first if it has never been
  /// built. Returns the best-matching notebooks/notes, most-relevant first. An
  /// empty/whitespace query yields no hits.
  Future<List<VaultSearchHit>> search(String query) async {
    if (query.trim().isEmpty) return const [];
    if (!_built) await rebuild();

    final sources = <String, String>{
      for (final e in _entries) e.id: e.text,
    };
    final ranked = rankHits(sources, query);
    final byId = {for (final e in _entries) e.id: e};

    final hits = <VaultSearchHit>[];
    for (final hit in ranked) {
      final entry = byId[hit.ref];
      if (entry == null) continue;
      hits.add(VaultSearchHit(entry: entry, snippet: hit.snippet));
    }
    return hits;
  }

  /// Concatenate every searchable string from one sidecar: the [title], every
  /// typed text box across all pages, and the persisted handwriting OCR text.
  static String _harvest({
    required String title,
    required BadnoteSidecar? sidecar,
  }) {
    final parts = <String>[title];
    if (sidecar != null) {
      // Typed text boxes: a stroke carries `textContent` regardless of tool
      // (EditorTool has only pen/highlighter/eraser; text is a content flag).
      for (final pageStrokes in sidecar.strokes.values) {
        for (final stroke in pageStrokes) {
          final text = stroke.textContent;
          if (text != null && text.trim().isNotEmpty) {
            parts.add(text.trim());
          }
        }
      }
      final ocr = sidecar.ocrText;
      if (ocr != null && ocr.trim().isNotEmpty) parts.add(ocr.trim());
    }
    return parts.join('\n');
  }
}
