import 'dart:io';

import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';

import '../storage/badnote_sidecar.dart';
import '../storage/sidecar_store.dart';

/// Suffix appended to a source-file path to form its sidecar path. Kept in sync
/// with [SidecarRepository.kSidecarSuffix]; duplicated here to avoid a layering
/// dependency from the service onto the editor.
const String kVaultSidecarSuffix = '.badnote.json';

/// A notebook discovered by scanning the vault: one folder holding a source
/// file (and, optionally, its sidecar). This is the file-backed source of truth
/// for the home screen list (the SQLite `documents` table is no longer read).
class VaultNotebook {
  const VaultNotebook({
    required this.folderPath,
    required this.sourceFilePath,
    required this.filename,
    required this.docType,
    required this.modified,
    this.hasSidecar = false,
  });

  /// Absolute path to the notebook folder.
  final String folderPath;

  /// Absolute path to the annotatable source file inside the folder.
  final String sourceFilePath;

  /// Source filename including extension, e.g. `Calculus Lecture 3.pdf`.
  final String filename;

  /// Lowercased extension without the dot: `pdf` / `pptx` / `ppt` / `docx`.
  final String docType;

  /// Last-modified time of the source file (used for recency sorting).
  final DateTime modified;

  /// Whether a `<file>.badnote.json` sidecar exists next to the source file.
  final bool hasSidecar;
}

/// Basename (without the sidecar suffix) of a standalone notebook's synthetic
/// note "source". Opening `SidecarRepository.open('<folder>/notebook', …)`
/// therefore writes `<folder>/notebook.badnote.json`.
const String kNotebookBaseName = 'notebook';

/// Full sidecar filename for a standalone (free-ink) notebook folder.
const String kNotebookSidecarName = '$kNotebookBaseName$kVaultSidecarSuffix';

/// A standalone (non-file-backed) free-ink notebook discovered by scanning the
/// vault: a folder holding a `notebook.badnote.json` and NO importable source
/// file. This is the file-based replacement for the old SQLite `notes` table.
class VaultNote {
  const VaultNote({
    required this.folderPath,
    required this.notePath,
    required this.title,
    required this.modified,
  });

  /// Absolute path to the notebook folder.
  final String folderPath;

  /// Synthetic note "source" path `<folder>/notebook`. Pass this to
  /// `SidecarRepository.open(notePath, docType: 'notebook')`; it keys the
  /// `<folder>/notebook.badnote.json` sidecar. Doubles as the note's stable id.
  final String notePath;

  /// Display title (from the sidecar's `title`, falling back to the folder name).
  final String title;

  /// Last-modified time of the sidecar (used for recency sorting).
  final DateTime modified;
}

/// Records the user-picked vault root folder (an Obsidian-style vault) and
/// gates app startup behind a valid choice.
///
/// The vault root is the single folder under which all notebooks will live.
/// Phase 0 only persists the path and validates it exists; no data is moved
/// into the vault yet (later phases do that).
///
/// Persistence is SharedPreferences-backed under [vaultRootKey]. The service is
/// easily mockable: inject a [SharedPreferences] (e.g. from
/// `SharedPreferences.setMockInitialValues`) via the constructor for tests.
class VaultService {
  /// SharedPreferences key under which the vault root path is stored.
  static const String vaultRootKey = 'vaultRoot';

  /// SharedPreferences key gating the one-time SQLite→sidecar migration
  /// (Phase 5). Set true once the migration completes so it never re-runs.
  static const String vaultMigrationDoneKey = 'vaultMigrationDone';

  final SharedPreferences _prefs;

  VaultService._(this._prefs);

  static VaultService? _instance;

  /// Singleton accessor, mirroring [DatabaseService.getInstance]. Lazily reads
  /// the shared [SharedPreferences] instance.
  static Future<VaultService> getInstance() async {
    if (_instance != null) return _instance!;
    final prefs = await SharedPreferences.getInstance();
    final service = VaultService._(prefs);
    _instance = service;
    return service;
  }

  /// Test-only constructor: inject a (typically mock) [SharedPreferences] so
  /// the vault root can be exercised without platform channels.
  @visibleForTesting
  VaultService.forTest(SharedPreferences prefs) : _prefs = prefs;

  /// Test-only: drop the cached singleton so the next [getInstance] rebuilds.
  @visibleForTesting
  static void resetForTest() {
    _instance = null;
  }

  /// The currently stored vault root path, or null if none has been chosen.
  String? get vaultRoot => _prefs.getString(vaultRootKey);

  /// Persist [path] as the vault root.
  Future<void> setVaultRoot(String path) async {
    await _prefs.setString(vaultRootKey, path);
  }

  /// Forget the stored vault root (e.g. to re-prompt the user).
  Future<void> clearVaultRoot() async {
    await _prefs.remove(vaultRootKey);
  }

  /// True once the one-time SQLite→sidecar migration (Phase 5) has completed.
  /// When false, startup runs the migrator before opening the home screen.
  bool get vaultMigrationDone => _prefs.getBool(vaultMigrationDoneKey) ?? false;

  /// Mark the one-time SQLite→sidecar migration as done so it never re-runs.
  Future<void> setVaultMigrationDone() async {
    await _prefs.setBool(vaultMigrationDoneKey, true);
  }

  /// True iff a vault root is set AND that directory currently exists.
  ///
  /// Returns false when no path is stored or when the stored path no longer
  /// resolves to a directory (external drive unplugged, folder deleted) — the
  /// caller then re-prompts rather than silently scattering data elsewhere.
  Future<bool> vaultRootValid() async {
    final path = vaultRoot;
    if (path == null || path.isEmpty) return false;
    return Directory(path).exists();
  }

  /// Source-file extensions BadNote can import as notebooks.
  static const Set<String> importableExtensions = {'pdf', 'docx', 'pptx', 'ppt'};

  /// Create a notebook FOLDER under the vault root, COPY the source file at
  /// [sourceFilePath] into it, and return the path of the in-vault copy.
  ///
  /// The folder name is the sanitized source basename (without extension),
  /// de-duplicated with a numeric suffix on collision (`Lecture`, `Lecture 2`,
  /// …). The sidecar (`<file>.badnote.json`) will live next to the copy — the
  /// [SidecarRepository] keys off the returned path, so nothing else is needed.
  ///
  /// Throws [StateError] if no valid vault root is set.
  Future<String> createNotebook(String sourceFilePath) async {
    final root = vaultRoot;
    if (root == null || root.isEmpty) {
      throw StateError('No vault root is set; cannot create a notebook.');
    }
    final source = File(sourceFilePath);
    final filename = p.basename(sourceFilePath);
    final baseName = _sanitizeFolderName(p.basenameWithoutExtension(filename));

    final folder = await _uniqueNotebookFolder(root, baseName);
    await folder.create(recursive: true);

    final destPath = p.join(folder.path, filename);
    await source.copy(destPath);
    return destPath;
  }

  /// Scan the vault root for notebook folders. A notebook is a direct
  /// subfolder (excluding the hidden `.badnote` metadata folder) that contains
  /// at least one importable source file. Returns the notebooks sorted by
  /// source-file mtime, most-recent first. An empty / missing vault yields an
  /// empty list (never throws).
  Future<List<VaultNotebook>> scanNotebooks() async {
    final root = vaultRoot;
    if (root == null || root.isEmpty) return const [];
    final dir = Directory(root);
    if (!await dir.exists()) return const [];

    final notebooks = <VaultNotebook>[];
    await for (final entity in dir.list(followLinks: false)) {
      if (entity is! Directory) continue;
      final folderName = p.basename(entity.path);
      if (folderName.startsWith('.')) continue; // skip .badnote etc.

      final notebook = await _readNotebookFolder(entity);
      if (notebook != null) notebooks.add(notebook);
    }

    notebooks.sort((a, b) => b.modified.compareTo(a.modified));
    return notebooks;
  }

  /// Create an empty (free-ink) standalone notebook FOLDER under the vault root
  /// named from [title], write an initial `notebook.badnote.json` carrying that
  /// title (so the scan sees it immediately), and return the synthetic note
  /// path `<folder>/notebook`.
  ///
  /// Pass the returned path to `SidecarRepository.open(path, docType:
  /// 'notebook')`, which keys the folder's `notebook.badnote.json` — there is
  /// NO fake source file. Throws [StateError] if no valid vault root is set.
  Future<String> createEmptyNotebook(String title) async {
    final root = vaultRoot;
    if (root == null || root.isEmpty) {
      throw StateError('No vault root is set; cannot create a notebook.');
    }
    final trimmed = title.trim();
    final baseName = _sanitizeFolderName(trimmed);
    final folder = await _uniqueNotebookFolder(root, baseName);
    await folder.create(recursive: true);

    final notePath = p.join(folder.path, kNotebookBaseName);
    final now = DateTime.now().toUtc();
    final sidecar = BadnoteSidecar(
      docType: 'notebook',
      title: trimmed.isEmpty ? null : trimmed,
      createdAt: now,
      updatedAt: now,
    );
    await SidecarStore.writeAtomic(
      File('$notePath$kVaultSidecarSuffix'),
      sidecar,
    );
    return notePath;
  }

  /// Scan the vault root for standalone (free-ink) notebook folders: direct
  /// subfolders (excluding hidden `.` folders) that contain a
  /// `notebook.badnote.json` and NO importable source file. Returns them sorted
  /// by sidecar mtime, most-recent first. Missing / empty vault → empty list.
  ///
  /// File-backed document folders (which DO hold an importable source file) are
  /// surfaced by [scanNotebooks] instead, so the two scans never overlap.
  Future<List<VaultNote>> scanNotes() async {
    final root = vaultRoot;
    if (root == null || root.isEmpty) return const [];
    final dir = Directory(root);
    if (!await dir.exists()) return const [];

    final notes = <VaultNote>[];
    await for (final entity in dir.list(followLinks: false)) {
      if (entity is! Directory) continue;
      final folderName = p.basename(entity.path);
      if (folderName.startsWith('.')) continue;

      final note = await _readNoteFolder(entity);
      if (note != null) notes.add(note);
    }

    notes.sort((a, b) => b.modified.compareTo(a.modified));
    return notes;
  }

  /// Inspect a folder, returning a [VaultNote] iff it holds a
  /// `notebook.badnote.json` and NO importable source file, else null.
  Future<VaultNote?> _readNoteFolder(Directory folder) async {
    File? noteSidecar;
    var hasSource = false;
    await for (final entity in folder.list(followLinks: false)) {
      if (entity is! File) continue;
      final name = p.basename(entity.path);
      if (name == kNotebookSidecarName) {
        noteSidecar = entity;
        continue;
      }
      if (name.endsWith(kVaultSidecarSuffix)) continue;
      final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
      if (importableExtensions.contains(ext)) hasSource = true;
    }
    if (noteSidecar == null || hasSource) return null;

    final notePath = p.join(folder.path, kNotebookBaseName);
    final loaded = await SidecarStore.read(noteSidecar);
    final stat = await noteSidecar.stat();
    final title = (loaded?.title?.trim().isNotEmpty ?? false)
        ? loaded!.title!.trim()
        : p.basename(folder.path);
    return VaultNote(
      folderPath: folder.path,
      notePath: notePath,
      title: title,
      modified: stat.modified,
    );
  }

  /// Inspect a single notebook folder, returning a [VaultNotebook] when it
  /// holds an importable source file, else null. Picks the first importable
  /// file (prefers a `.pdf` so a DOCX→PDF-converted notebook opens as its PDF).
  Future<VaultNotebook?> _readNotebookFolder(Directory folder) async {
    File? chosen;
    String? chosenExt;
    await for (final entity in folder.list(followLinks: false)) {
      if (entity is! File) continue;
      final name = p.basename(entity.path);
      if (name.endsWith(kVaultSidecarSuffix)) continue;
      final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
      if (!importableExtensions.contains(ext)) continue;
      // Prefer a PDF artifact when present (DOCX-converted notebooks keep both).
      if (chosen == null || (ext == 'pdf' && chosenExt != 'pdf')) {
        chosen = entity;
        chosenExt = ext;
      }
    }
    if (chosen == null || chosenExt == null) return null;

    final stat = await chosen.stat();
    final sidecar = File('${chosen.path}$kVaultSidecarSuffix');
    return VaultNotebook(
      folderPath: folder.path,
      sourceFilePath: chosen.path,
      filename: p.basename(chosen.path),
      docType: chosenExt,
      modified: stat.modified,
      hasSidecar: await sidecar.exists(),
    );
  }

  /// Find an unused notebook folder under [root] for [baseName], appending a
  /// ` 2`, ` 3`, … suffix on collision.
  Future<Directory> _uniqueNotebookFolder(String root, String baseName) async {
    final safeBase = baseName.isEmpty ? 'Untitled' : baseName;
    var candidate = Directory(p.join(root, safeBase));
    var n = 2;
    while (await candidate.exists()) {
      candidate = Directory(p.join(root, '$safeBase $n'));
      n++;
    }
    return candidate;
  }

  /// Sanitize a basename into a safe folder name: strip characters illegal on
  /// Windows/POSIX (`\ / : * ? " < > |`) and control chars, collapse
  /// whitespace, and trim trailing dots/spaces (illegal on Windows).
  static String _sanitizeFolderName(String name) {
    final cleaned = name
        .replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), ' ')
        .replaceAll(RegExp(r'\s+'), ' ')
        .trim()
        .replaceAll(RegExp(r'[. ]+$'), '');
    return cleaned;
  }
}
