import { withTransaction } from "./db";
import type Database from "@tauri-apps/plugin-sql";
import { setPreference } from "./userPreferenceService";
import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
import type { BackupResult } from "./categoryBackupService";
import type { MigrationPlan, MappingRow } from "./categoryMappingService";

// -----------------------------------------------------------------------------
// Category migration service — orchestrates the atomic v2 → v1 SQL writeover
// using a MigrationPlan computed upstream by categoryMappingService.
//
// The service is intentionally destructive: it is only meant to be called
// *after* a verified pre-migration SREF backup (BackupResult) has been written
// to disk and confirmed by the user. The caller (CategoriesMigrationPage /
// useCategoryMigration hook) is responsible for sequencing backup → migrate →
// surface success/error — this service only runs the DB part.
//
// Ordering (all inside a single BEGIN / COMMIT transaction):
//   1.  Guard: backup looks valid (path + checksum present).
//   2.  BEGIN.
//   3.  If plan.preserved.length > 0: INSERT a new parent category
//       "Catégories personnalisées (migration)" (with i18n_key so it renders
//       in both languages), keep its new id.
//   4.  INSERT all v1 taxonomy nodes with explicit ids from the bundled
//       `categoryTaxonomyV1.json`. We skip ids that already exist in the DB
//       (OR IGNORE) so a re-run is safe.
//   5.  UPDATE transactions.category_id per plan.rows mapping (v2 → v1).
//   6.  UPDATE budget_entries.category_id and budget_template_entries.category_id
//       per plan.rows mapping (v2 → v1). We DELETE conflicting rows first
//       because budget_entries has UNIQUE(category_id, year, month) and
//       budget_template_entries has UNIQUE(template_id, category_id).
//   7.  UPDATE keywords.category_id per plan.rows mapping (v2 → v1). We
//       DELETE conflicting rows first because of UNIQUE(keyword, category_id).
//   8.  UPDATE suppliers.category_id per plan.rows mapping (v2 → v1).
//   9.  Re-parent preserved v2 custom categories under the new "Catégories
//       personnalisées (migration)" parent.
//   10. DELETE v2 seeded categories that are now empty (no transactions /
//       keywords / budgets / suppliers / child categories referencing them).
//       Deletion is soft (is_active=0) to preserve ON DELETE CASCADE from
//       historical budget_entries etc. in edge cases.
//   11. Set `categories_schema_version='v1'` and record
//       `last_categories_migration` JSON in user_preferences.
//   12. COMMIT. On any thrown error: ROLLBACK and report in MigrationOutcome.
// -----------------------------------------------------------------------------

export interface MigrationOutcome {
  /** True when the transaction committed; false if we rolled back or aborted. */
  succeeded: boolean;
  /** Human-readable error message on failure. Undefined on success. */
  error?: string;
  /** Number of v1 taxonomy rows we inserted (may be 0 on re-run). */
  insertedV1Count: number;
  /** Number of transactions whose category_id was rewritten. */
  updatedTransactionsCount: number;
  /** Number of budget_entries + budget_template_entries rows rewritten. */
  updatedBudgetsCount: number;
  /** Number of keywords rows rewritten. */
  updatedKeywordsCount: number;
  /** Number of v2 categories we deactivated (soft-delete). */
  deletedV2Count: number;
  /** Number of custom categories re-parented under the new parent. */
  customPreservedCount: number;
  /** Path to the SREF backup that was created before this run. */
  backupPath: string;
}

/** JSON journalled in user_preferences.last_categories_migration. */
export interface LastMigrationJournal {
  timestamp: string;
  backupPath: string;
  outcome: Pick<
    MigrationOutcome,
    | "insertedV1Count"
    | "updatedTransactionsCount"
    | "updatedBudgetsCount"
    | "updatedKeywordsCount"
    | "deletedV2Count"
    | "customPreservedCount"
  >;
}

// Preference keys we write at the end of a successful migration.
const SCHEMA_VERSION_KEY = "categories_schema_version";
const LAST_MIGRATION_KEY = "last_categories_migration";

// Id reserved for the "Catégories personnalisées (migration)" parent we
// create when the profile has custom v2 categories. We deliberately pick a
// number that is outside the v1 taxonomy range (1000 – 1999) and outside the
// v2 seed range (< 1000) to avoid collisions even on re-runs.
const CUSTOM_PARENT_NEW_ID = 2000;
const CUSTOM_PARENT_NAME = "Catégories personnalisées (migration)";
const CUSTOM_PARENT_I18N_KEY = "categoriesSeed.migration.customParent";
const CUSTOM_PARENT_COLOR = "#64748b";
const CUSTOM_PARENT_TYPE = "expense";

// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------

function flattenTaxonomy(root: TaxonomyNode, parentId: number | null, out: Array<TaxonomyFlat>): void {
  out.push({
    id: root.id,
    name: root.name,
    i18n_key: root.i18n_key,
    parent_id: parentId,
    type: root.type,
    color: root.color,
    sort_order: root.sort_order,
    is_inputable: root.children.length === 0,
  });
  for (const child of root.children) {
    flattenTaxonomy(child, root.id, out);
  }
}

interface TaxonomyFlat {
  id: number;
  name: string;
  i18n_key: string;
  parent_id: number | null;
  type: string;
  color: string;
  sort_order: number;
  is_inputable: boolean;
}

function listAllV1Rows(): TaxonomyFlat[] {
  const flat: TaxonomyFlat[] = [];
  for (const root of getTaxonomyV1().roots) {
    flattenTaxonomy(root, null, flat);
  }
  return flat;
}

/** Validate the backup looks usable — path + checksum present. */
function validateBackup(backup: BackupResult): void {
  if (!backup || typeof backup.path !== "string" || backup.path.length === 0) {
    throw new Error("invalid_backup: missing path");
  }
  if (typeof backup.checksum !== "string" || backup.checksum.length === 0) {
    throw new Error("invalid_backup: missing checksum");
  }
}

/** True when a mapping row carries a concrete v1 target (resolved / merged). */
function isResolvedTarget(row: MappingRow): boolean {
  return row.v1TargetId !== null && row.v1TargetId !== undefined;
}

/** Build the v2Id → v1Id map from mapping rows (only resolved targets kept). */
function buildMappingFromRows(rows: MappingRow[]): Map<number, number> {
  const map = new Map<number, number>();
  for (const row of rows) {
    if (isResolvedTarget(row)) {
      map.set(row.v2CategoryId, row.v1TargetId as number);
    }
  }
  return map;
}

// -----------------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------------

export async function applyMigration(
  plan: MigrationPlan,
  backup: BackupResult,
): Promise<MigrationOutcome> {
  const outcome: MigrationOutcome = {
    succeeded: false,
    insertedV1Count: 0,
    updatedTransactionsCount: 0,
    updatedBudgetsCount: 0,
    updatedKeywordsCount: 0,
    deletedV2Count: 0,
    customPreservedCount: 0,
    backupPath: backup?.path ?? "",
  };

  try {
    validateBackup(backup);
  } catch (e) {
    outcome.error = e instanceof Error ? e.message : String(e);
    return outcome;
  }

  return withTransaction((db) =>
    applyMigrationInTransaction(db, plan, backup, outcome),
  );
}

async function applyMigrationInTransaction(
  db: Database,
  plan: MigrationPlan,
  backup: BackupResult,
  outcome: MigrationOutcome,
): Promise<MigrationOutcome> {
  // Seeded rows AND merged custom categories (preserved rows the user gave a
  // target) share the same rewrite: their transactions, budgets, keywords and
  // suppliers are reassigned to the chosen v1 leaf. Unresolved preserved rows
  // have a null target and are filtered out by buildMappingFromRows.
  const allMappableRows = [...plan.rows, ...plan.preserved];
  const mapping = buildMappingFromRows(allMappableRows);

  await db.execute("BEGIN");
  try {
    // 1. Optionally create the "custom categories (migration)" parent — only
    //    when at least one custom category is left UNMERGED. If every custom
    //    was merged into a standard leaf, the bucket would be empty, so skip it.
    let customParentId: number | null = null;
    const hasUnmergedPreserved = plan.preserved.some((p) => !isResolvedTarget(p));
    if (hasUnmergedPreserved) {
      // Use INSERT OR IGNORE so a re-run never throws on the PK.
      await db.execute(
        `INSERT OR IGNORE INTO categories
           (id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key)
         VALUES ($1, $2, NULL, $3, $4, 1, 0, 99, $5)`,
        [
          CUSTOM_PARENT_NEW_ID,
          CUSTOM_PARENT_NAME,
          CUSTOM_PARENT_COLOR,
          CUSTOM_PARENT_TYPE,
          CUSTOM_PARENT_I18N_KEY,
        ],
      );
      customParentId = CUSTOM_PARENT_NEW_ID;
    }

    // 2. INSERT v1 taxonomy. Roots first, then subcategories, then leaves,
    //    thanks to flattenTaxonomy's depth-first walk. Use OR IGNORE so a
    //    partial earlier run is recoverable.
    const v1Rows = listAllV1Rows();
    for (const row of v1Rows) {
      const result = await db.execute(
        `INSERT OR IGNORE INTO categories
           (id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key)
         VALUES ($1, $2, $3, $4, $5, 1, $6, $7, $8)`,
        [
          row.id,
          row.name,
          row.parent_id,
          row.color,
          row.type,
          row.is_inputable ? 1 : 0,
          row.sort_order,
          row.i18n_key,
        ],
      );
      // tauri-plugin-sql returns rowsAffected; on OR IGNORE conflicts it's 0.
      const affected = Number(result.rowsAffected ?? 0);
      outcome.insertedV1Count += affected;
    }

    // 3. Rewrite transactions.category_id v2 → v1.
    for (const [v2Id, v1Id] of mapping.entries()) {
      const r = await db.execute(
        `UPDATE transactions SET category_id = $1, updated_at = CURRENT_TIMESTAMP
         WHERE category_id = $2`,
        [v1Id, v2Id],
      );
      outcome.updatedTransactionsCount += Number(r.rowsAffected ?? 0);
    }

    // 4. Rewrite budget_entries (handle UNIQUE(category_id, year, month) by
    //    deleting rows we'd collide with first — in a preview-and-consent
    //    flow, the collision means the user already has a budget on the v1
    //    target for the same period, so dropping the v2 duplicate is the
    //    least-surprising choice).
    for (const [v2Id, v1Id] of mapping.entries()) {
      await db.execute(
        `DELETE FROM budget_entries
         WHERE category_id = $1
           AND (year, month) IN (
             SELECT year, month FROM budget_entries WHERE category_id = $2
           )`,
        [v2Id, v1Id],
      );
      const r = await db.execute(
        `UPDATE budget_entries SET category_id = $1, updated_at = CURRENT_TIMESTAMP
         WHERE category_id = $2`,
        [v1Id, v2Id],
      );
      outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0);
    }

    // 5. Rewrite budget_template_entries (same collision rule via
    //    UNIQUE(template_id, category_id)).
    for (const [v2Id, v1Id] of mapping.entries()) {
      await db.execute(
        `DELETE FROM budget_template_entries
         WHERE category_id = $1
           AND template_id IN (
             SELECT template_id FROM budget_template_entries WHERE category_id = $2
           )`,
        [v2Id, v1Id],
      );
      const r = await db.execute(
        `UPDATE budget_template_entries SET category_id = $1
         WHERE category_id = $2`,
        [v1Id, v2Id],
      );
      outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0);
    }

    // 6. Rewrite keywords (UNIQUE(keyword, category_id)). Drop v2 keywords
    //    whose normalized spelling already points at the v1 target before the
    //    UPDATE, to avoid constraint violations.
    for (const [v2Id, v1Id] of mapping.entries()) {
      await db.execute(
        `DELETE FROM keywords
         WHERE category_id = $1
           AND keyword IN (
             SELECT keyword FROM keywords WHERE category_id = $2
           )`,
        [v2Id, v1Id],
      );
      const r = await db.execute(
        `UPDATE keywords SET category_id = $1 WHERE category_id = $2`,
        [v1Id, v2Id],
      );
      outcome.updatedKeywordsCount += Number(r.rowsAffected ?? 0);
    }

    // 7. Rewrite suppliers.category_id — no unique constraint, straightforward.
    for (const [v2Id, v1Id] of mapping.entries()) {
      await db.execute(
        `UPDATE suppliers SET category_id = $1, updated_at = CURRENT_TIMESTAMP
         WHERE category_id = $2`,
        [v1Id, v2Id],
      );
    }

    // 8. Re-parent preserved custom categories under the new parent. We touch
    //    only the top level of the custom tree (parent_id IS NULL or pointing
    //    at a v2 structural parent in the 1..6 range): children follow naturally.
    if (customParentId !== null) {
      for (const preservedRow of plan.preserved) {
        // Merged customs are deactivated in the soft-delete step below, not
        // re-parented — only the ones left unmerged move under the bucket.
        if (isResolvedTarget(preservedRow)) continue;
        const r = await db.execute(
          `UPDATE categories SET parent_id = $1 WHERE id = $2`,
          [customParentId, preservedRow.v2CategoryId],
        );
        outcome.customPreservedCount += Number(r.rowsAffected ?? 0);
      }
    }

    // 9. Soft-delete every category whose data we just rewrote: mapped v2 seed
    //    rows AND merged custom categories. We deactivate (is_active=0) instead
    //    of hard-deleting so any historical reference we might have missed stays
    //    intact and hidden from the UI lists.
    for (const row of allMappableRows) {
      // Skip rows with no target. Seed rows can't reach here (consent is
      // blocked until they're all resolved), but a custom left unmerged
      // legitimately does — it must stay alive under the bucket, not be
      // deactivated.
      if (!isResolvedTarget(row)) continue;
      const r = await db.execute(
        `UPDATE categories SET is_active = 0 WHERE id = $1`,
        [row.v2CategoryId],
      );
      outcome.deletedV2Count += Number(r.rowsAffected ?? 0);
    }

    // 10. Also deactivate the v2 structural parents (1..6) — they have no v1
    //     equivalent and become obsolete after the migration.
    {
      const r = await db.execute(
        `UPDATE categories SET is_active = 0
         WHERE id IN (1, 2, 3, 4, 5, 6)`,
      );
      outcome.deletedV2Count += Number(r.rowsAffected ?? 0);
    }

    // 11. Bump the schema version and journal the run.
    await db.execute(
      `INSERT INTO user_preferences (key, value, updated_at)
       VALUES ($1, 'v1', CURRENT_TIMESTAMP)
       ON CONFLICT(key) DO UPDATE SET value = 'v1', updated_at = CURRENT_TIMESTAMP`,
      [SCHEMA_VERSION_KEY],
    );

    const journal: LastMigrationJournal = {
      timestamp: new Date().toISOString(),
      backupPath: backup.path,
      outcome: {
        insertedV1Count: outcome.insertedV1Count,
        updatedTransactionsCount: outcome.updatedTransactionsCount,
        updatedBudgetsCount: outcome.updatedBudgetsCount,
        updatedKeywordsCount: outcome.updatedKeywordsCount,
        deletedV2Count: outcome.deletedV2Count,
        customPreservedCount: outcome.customPreservedCount,
      },
    };
    await db.execute(
      `INSERT INTO user_preferences (key, value, updated_at)
       VALUES ($1, $2, CURRENT_TIMESTAMP)
       ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`,
      [LAST_MIGRATION_KEY, JSON.stringify(journal)],
    );

    await db.execute("COMMIT");
    outcome.succeeded = true;
    return outcome;
  } catch (e) {
    try {
      await db.execute("ROLLBACK");
    } catch {
      // Swallow: if the rollback itself fails there is nothing we can do here
      // besides returning the original error to the caller.
    }
    outcome.error = e instanceof Error ? e.message : String(e);
    outcome.succeeded = false;
    return outcome;
  }
}

/**
 * Convenience: mark the schema as v1 without running the full migration.
 * Exported for tests and tooling — the happy-path user flow goes through
 * `applyMigration` which handles this transactionally.
 */
export async function markSchemaVersionV1(): Promise<void> {
  await setPreference(SCHEMA_VERSION_KEY, "v1");
}
