import { invoke } from "@tauri-apps/api/core";
import {
  parseImportedJson,
  importTransactionsWithCategories,
} from "./dataExportService";
import { getPreference, setPreference } from "./userPreferenceService";
import type { LastMigrationJournal } from "./categoryMigrationService";

// -----------------------------------------------------------------------------
// Category restore service — rolls back the v2 → v1 categories migration by
// importing the pre-migration SREF backup created by categoryBackupService.
//
// The service is destructive by design: it wipes and re-inserts the entire
// profile from the backup payload (same semantics as the full-profile
// "transactions_with_categories" import). Calling it is the user's explicit
// choice, gated by a confirmation modal in the UI layer.
//
// Flow:
//   1. Resolve the backup file path — either the one recorded in
//      `last_categories_migration.backupPath`, or a user-picked file when the
//      recorded path no longer exists on disk.
//   2. Read it via `read_import_file` (Rust transparently handles the SREF
//      AES-256-GCM decryption when the file starts with the magic bytes).
//   3. Parse it, assert `export_type === "transactions_with_categories"`.
//   4. Call `importTransactionsWithCategories` to wipe + restore everything.
//   5. Reset `categories_schema_version` back to `v2` and merge a `reverted_at`
//      timestamp into `last_categories_migration`.
//
// The service never swallows errors — the caller (modal) is responsible for
// surfacing them via i18n-mapped messages.
// -----------------------------------------------------------------------------

export const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version";
export const LAST_CATEGORIES_MIGRATION_KEY = "last_categories_migration";
export const CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY =
  "categories_migration_banner_dismissed";

/** Extended journal shape: the base written by applyMigration plus the
 *  optional `reverted_at` stamped by a successful restore. */
export interface RestorableMigrationJournal extends LastMigrationJournal {
  reverted_at?: string;
}

export interface RestoreResult {
  /** Path that was actually restored from (may differ from the recorded one
   *  when the user picked a fallback file). */
  filePath: string;
  /** ISO-8601 timestamp recorded on `last_categories_migration.reverted_at`. */
  revertedAt: string;
}

export type RestoreErrorCode =
  | "file_missing"
  | "read_failed"
  | "parse_failed"
  | "wrong_envelope_type"
  | "no_recorded_migration"
  | "needs_password"
  | "wrong_password"
  | "import_failed";

export class RestoreError extends Error {
  public readonly code: RestoreErrorCode;
  public readonly detail: string;

  constructor(code: RestoreErrorCode, detail: string) {
    super(`restore_failed:${code}:${detail}`);
    this.name = "RestoreError";
    this.code = code;
    this.detail = detail;
  }
}

// -----------------------------------------------------------------------------
// Journal helpers — safe JSON parsing with best-effort typing
// -----------------------------------------------------------------------------

/**
 * Read and parse the journal written by applyMigration. Returns `null` when
 * no migration has ever been run, or when the stored value is not valid JSON
 * (we never throw on that — a corrupted journal is equivalent to "no record"
 * for UI purposes). Shape is validated loosely: we only require `timestamp`
 * and `backupPath` strings, which are the fields the restore flow actually
 * consumes.
 */
export async function readLastMigrationJournal(): Promise<
  RestorableMigrationJournal | null
> {
  const raw = await getPreference(LAST_CATEGORIES_MIGRATION_KEY);
  if (!raw) return null;

  try {
    const parsed = JSON.parse(raw) as Partial<RestorableMigrationJournal>;
    if (
      typeof parsed.timestamp !== "string" ||
      typeof parsed.backupPath !== "string"
    ) {
      return null;
    }
    return parsed as RestorableMigrationJournal;
  } catch {
    return null;
  }
}

/**
 * Compute whether a migration is within the 90-day banner window. Pure
 * function — exported for tests.
 */
export function isWithinBannerWindow(
  journalTimestamp: string,
  nowMs: number = Date.now(),
  windowDays: number = 90,
): boolean {
  const ts = Date.parse(journalTimestamp);
  if (Number.isNaN(ts)) return false;
  const ageMs = nowMs - ts;
  const windowMs = windowDays * 24 * 60 * 60 * 1000;
  return ageMs >= 0 && ageMs <= windowMs;
}

/**
 * Check whether the banner should be shown given the three inputs we read
 * from user_preferences:
 *   - journal: the parsed `last_categories_migration` JSON (null ⇒ never);
 *   - dismissedPref: the raw value of `categories_migration_banner_dismissed`;
 *   - now: the current clock (injectable for tests).
 *
 * Pure function — exported so the UI and tests share the same rules.
 */
export function shouldShowBanner(
  journal: RestorableMigrationJournal | null,
  dismissedPref: string | null,
  now: number = Date.now(),
): boolean {
  if (!journal) return false;
  if (journal.reverted_at) return false;
  if (dismissedPref === "1") return false;
  return isWithinBannerWindow(journal.timestamp, now);
}

/**
 * Merge a `reverted_at` stamp into the existing journal and persist it.
 * Leaves the rest of the object intact. No-op when there is no recorded
 * journal — the caller must have one (the restore flow is only reachable
 * when we successfully read one upstream).
 */
export async function markMigrationReverted(
  revertedAt: string = new Date().toISOString(),
): Promise<void> {
  const journal = await readLastMigrationJournal();
  if (!journal) return;
  const updated: RestorableMigrationJournal = {
    ...journal,
    reverted_at: revertedAt,
  };
  await setPreference(LAST_CATEGORIES_MIGRATION_KEY, JSON.stringify(updated));
}

// -----------------------------------------------------------------------------
// Tauri wrappers
// -----------------------------------------------------------------------------

/** Check whether a file is still on disk. Returns false when the path was
 *  moved or removed. */
export async function backupFileExists(path: string): Promise<boolean> {
  try {
    return await invoke<boolean>("file_exists", { filePath: path });
  } catch {
    // If even the stat call fails we treat the file as missing so the UI
    // falls back to the manual file picker rather than hanging.
    return false;
  }
}

/** Detect whether a file is SREF-encrypted (has the `SREF` magic prefix). */
export async function isFileEncrypted(path: string): Promise<boolean> {
  return invoke<boolean>("is_file_encrypted", { filePath: path });
}

/** Open the native file picker filtered to SREF/JSON. Returns null when the
 *  user cancels. */
export async function pickBackupFile(): Promise<string | null> {
  return invoke<string | null>("pick_import_file", {
    filters: [["Simpl'Result Backup", ["sref", "json"]]],
  });
}

// -----------------------------------------------------------------------------
// Core restore flow — wipes the profile and re-imports the SREF payload
// -----------------------------------------------------------------------------

/**
 * Restore a profile from a pre-migration SREF backup and mark the migration
 * as reverted. This is the destructive write performed after confirmation.
 *
 * @param filePath   Absolute path to the backup SREF/JSON file.
 * @param password   Clear-text PIN — required when the file is encrypted
 *                   (SREF magic detected). Pass `null` for plaintext JSON
 *                   backups or when the profile had no PIN.
 *
 * Throws a `RestoreError` on any failure; on success updates
 * `categories_schema_version=v2` and stamps `reverted_at` on the journal.
 */
export async function restoreFromBackup(
  filePath: string,
  password: string | null,
): Promise<RestoreResult> {
  // 1. Read the file — decryption happens inside read_import_file when the
  //    magic prefix is present.
  let content: string;
  try {
    content = await invoke<string>("read_import_file", {
      filePath,
      password,
    });
  } catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    const lower = message.toLowerCase();
    if (lower.includes("password is required")) {
      throw new RestoreError("needs_password", message);
    }
    if (
      lower.includes("decryption failed") ||
      lower.includes("wrong password")
    ) {
      throw new RestoreError("wrong_password", message);
    }
    throw new RestoreError("read_failed", message);
  }

  // 2. Parse the envelope. The export_type MUST be
  //    `transactions_with_categories` — anything else would be a user error
  //    (they picked the wrong file).
  let envelope;
  try {
    envelope = parseImportedJson(content).envelope;
  } catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    throw new RestoreError("parse_failed", message);
  }

  if (envelope.export_type !== "transactions_with_categories") {
    throw new RestoreError(
      "wrong_envelope_type",
      `got ${envelope.export_type}, expected transactions_with_categories`,
    );
  }

  // 3. Perform the destructive re-import. importTransactionsWithCategories
  //    wipes transactions/imported_files/import_sources/keywords/suppliers/
  //    categories and re-inserts them from the envelope.
  const filename = filePath.split(/[/\\]/).pop() ?? "backup.sref";
  try {
    await importTransactionsWithCategories(envelope.data, filename);
  } catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    throw new RestoreError("import_failed", message);
  }

  // 4. Reset schema version and stamp the journal. Both are best-effort in the
  //    sense that the DB restore has already succeeded by this point — we
  //    still surface a preference write failure because stale prefs would
  //    make the banner misleading.
  const revertedAt = new Date().toISOString();
  await setPreference(CATEGORIES_SCHEMA_VERSION_KEY, "v2");
  await markMigrationReverted(revertedAt);

  return { filePath, revertedAt };
}
