mod commands;
mod database;

use std::sync::Mutex;
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_sql::{Migration, MigrationKind};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let migrations = vec![
        Migration {
            version: 1,
            description: "create initial schema",
            sql: database::SCHEMA,
            kind: MigrationKind::Up,
        },
        Migration {
            version: 2,
            description: "seed categories and keywords",
            sql: database::SEED_CATEGORIES,
            kind: MigrationKind::Up,
        },
        Migration {
            version: 3,
            description: "add has_header to import_sources",
            sql: "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;",
            kind: MigrationKind::Up,
        },
        Migration {
            version: 4,
            description: "add is_inputable to categories",
            sql: "ALTER TABLE categories ADD COLUMN is_inputable INTEGER NOT NULL DEFAULT 1;",
            kind: MigrationKind::Up,
        },
        Migration {
            version: 5,
            description: "create import_config_templates table",
            sql: "CREATE TABLE IF NOT EXISTS import_config_templates (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL UNIQUE,
                delimiter TEXT NOT NULL DEFAULT ';',
                encoding TEXT NOT NULL DEFAULT 'utf-8',
                date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
                skip_lines INTEGER NOT NULL DEFAULT 0,
                has_header INTEGER NOT NULL DEFAULT 1,
                column_mapping TEXT NOT NULL,
                amount_mode TEXT NOT NULL DEFAULT 'single',
                sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
            );",
            kind: MigrationKind::Up,
        },
        Migration {
            version: 6,
            description: "change imported_files unique constraint from hash to filename",
            sql: "CREATE TABLE imported_files_new (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                source_id INTEGER NOT NULL REFERENCES import_sources(id),
                filename TEXT NOT NULL,
                file_hash TEXT NOT NULL,
                import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                row_count INTEGER NOT NULL DEFAULT 0,
                status TEXT NOT NULL DEFAULT 'completed',
                notes TEXT,
                UNIQUE(source_id, filename)
            );
            INSERT INTO imported_files_new SELECT * FROM imported_files;
            DROP TABLE imported_files;
            ALTER TABLE imported_files_new RENAME TO imported_files;",
            kind: MigrationKind::Up,
        },
        Migration {
            version: 7,
            description: "add level-3 insurance subcategories",
            sql: "INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1);
            INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2);
            INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3);
            UPDATE categories SET is_inputable = 0 WHERE id = 31;
            UPDATE keywords SET category_id = 310 WHERE keyword = 'BELAIR' AND category_id = 31;
            UPDATE keywords SET category_id = 311 WHERE keyword = 'PRYSM' AND category_id = 31;
            UPDATE keywords SET category_id = 312 WHERE keyword = 'INS/ASS' AND category_id = 31;",
            kind: MigrationKind::Up,
        },
        // Migration v8 — additive: tag existing profiles with the v2 categories
        // taxonomy and add a nullable i18n_key column on categories so the v1
        // IPC seed (applied only to brand-new profiles via consolidated_schema)
        // can store translation keys. Existing v2 profiles are untouched: the
        // column defaults to NULL (falling back to the category's `name`) and
        // the preference is a no-op INSERT OR IGNORE so re-runs are safe.
        Migration {
            version: 8,
            description: "add i18n_key to categories and categories_schema_version preference",
            sql: "ALTER TABLE categories ADD COLUMN i18n_key TEXT;
            INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
            kind: MigrationKind::Up,
        },
        // Migration v9 — Bilan (balance sheet) schema:
        // 5 tables (balance_categories, balance_accounts, balance_snapshots,
        // balance_snapshot_lines, balance_account_transfers) + 7 indexes +
        // 7 seeded categories (5 simple + 2 priced).
        // CHECK(currency = 'CAD') is hardcoded for the MVP (will be lifted in v2
        // with a multi-currency rate table). FK transaction_id ON DELETE
        // RESTRICT preserves reproducibility of Modified Dietz returns.
        Migration {
            version: 9,
            description: "create balance schema",
            sql: database::BALANCE_SCHEMA,
            kind: MigrationKind::Up,
        },
        // Migration v10 — additive: a nullable `asset_type` column on
        // balance_categories so the priced-kind UI can route between providers
        // (best-effort Yahoo for stocks, exchange APIs for crypto). Symbol
        // alone is ambiguous (e.g. ETH = Ethan Allen NYSE AND Ethereum crypto).
        // ALTER first → adds NULL column, then UPDATE backfills the two
        // priced seeds (`stock`, `crypto`) created by v9. Custom priced rows
        // keep NULL until the user edits the category — SnapshotLineRow hides
        // the fetch button while asset_type is NULL.
        Migration {
            version: 10,
            description: "add asset_type to balance_categories",
            sql: "ALTER TABLE balance_categories ADD COLUMN asset_type TEXT \
                  CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')); \
                  UPDATE balance_categories SET asset_type = 'stock' \
                    WHERE key = 'stock' AND is_seed = 1; \
                  UPDATE balance_categories SET asset_type = 'crypto' \
                    WHERE key = 'crypto' AND is_seed = 1;",
            kind: MigrationKind::Up,
        },
        // Migration v11 — cleanup orphan balance snapshots (#176). Before
        // useSnapshotEditor.save was made atomic via BEGIN/COMMIT, a
        // priced-line validation failure could leave the snapshot row
        // inserted but with no lines, blocking subsequent saves at that
        // date through the snapshot_date UNIQUE constraint. This deletes
        // any such orphan rows from existing profiles. New orphans are
        // no longer possible thanks to saveSnapshotAtomic.
        Migration {
            version: 11,
            description: "cleanup orphan balance snapshots",
            sql: "DELETE FROM balance_snapshots \
                  WHERE NOT EXISTS ( \
                    SELECT 1 FROM balance_snapshot_lines \
                     WHERE snapshot_id = balance_snapshots.id);",
            kind: MigrationKind::Up,
        },
        // Migration v12 — Bilan axe véhicule (Étape 1), additive part. Adds
        // `vehicle_type` (fiscal envelope / tax shelter — NOT an automobile) to
        // balance_accounts and `custom_label` to balance_categories, then
        // backfills the envelope attribute onto the accounts that used to live
        // in the now-deprecated `tfsa` / `rrsp` seed categories. `cash` accounts
        // stay NULL — a chequing account is not a "non-registered investment".
        //
        // The two trailing UPDATEs are a defensive backfill for bug I: before
        // this work the category-rename path overwrote `i18n_key` with free
        // text. Any seed row whose i18n_key no longer matches the canonical
        // `balance.category.%` convention has that free text recovered into
        // `custom_label`, then its i18n_key is restored from `key` so the
        // renderCategoryLabel helper (custom_label || t(i18n_key)) keeps working.
        // ALTER ADD COLUMN ... CHECK (mono-column) is valid on this stack (cf v10).
        Migration {
            version: 12,
            description: "add vehicle_type to balance_accounts and custom_label to balance_categories",
            sql: "ALTER TABLE balance_accounts ADD COLUMN vehicle_type TEXT \
                  CHECK(vehicle_type IS NULL OR vehicle_type IN \
                        ('unregistered','tfsa','rrsp','rrif','fhsa','resp')); \
                  ALTER TABLE balance_categories ADD COLUMN custom_label TEXT; \
                  UPDATE balance_accounts SET vehicle_type = 'tfsa' \
                    WHERE balance_category_id = ( \
                      SELECT id FROM balance_categories WHERE key = 'tfsa'); \
                  UPDATE balance_accounts SET vehicle_type = 'rrsp' \
                    WHERE balance_category_id = ( \
                      SELECT id FROM balance_categories WHERE key = 'rrsp'); \
                  UPDATE balance_categories SET custom_label = i18n_key \
                    WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%'; \
                  UPDATE balance_categories SET i18n_key = 'balance.category.' || key \
                    WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%';",
            kind: MigrationKind::Up,
        },
        // Migration v13 — Bilan axe véhicule (Étape 1), reclass part. Now that
        // the envelope lives on the account (v12), the `tfsa` / `rrsp` seed
        // categories become pure tax shelters with no asset-class meaning.
        // Re-link their accounts to the `other` asset class ("Autres") and
        // deactivate the two seeds so they vanish from the category dropdowns.
        //
        // Conditional + idempotent (no Down migration exists): the re-link only
        // fires when an `other` seed category exists (EXISTS guard), and both
        // statements scope to `is_seed = 1` so a user-created category that
        // happens to reuse the key is never touched. v12 runs first (sqlx
        // versioning guarantees the order), so vehicle_type is already stamped
        // before balance_category_id moves. Re-running v13 is a no-op: the
        // accounts are no longer in tfsa/rrsp and the seeds are already inactive.
        Migration {
            version: 13,
            description: "reclass ex-tfsa/rrsp accounts to other and deactivate the envelope seeds",
            sql: "UPDATE balance_accounts \
                    SET balance_category_id = ( \
                      SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1) \
                    WHERE balance_category_id IN ( \
                      SELECT id FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_seed = 1) \
                    AND EXISTS ( \
                      SELECT 1 FROM balance_categories WHERE key = 'other' AND is_seed = 1); \
                  UPDATE balance_categories SET is_active = 0 \
                    WHERE key IN ('tfsa','rrsp') AND is_seed = 1;",
            kind: MigrationKind::Up,
        },
        // Migration v14 — Bilan détail par titre (Étape 2), foundation part.
        // Purely additive: two new tables that let a single account hold many
        // securities at a snapshot date, instead of one denormalized value.
        //   - balance_securities: the catalogue of investable instruments
        //     (a stock or a crypto). `symbol` is the natural key, stored
        //     normalized (upper/trim by the service layer) with COLLATE NOCASE
        //     so 'aapl' and 'AAPL' can never coexist as duplicates (SEC/ARCH
        //     review caveat). `asset_type` mirrors balance_categories.asset_type
        //     so the price-fetch flow can route stock vs crypto per security.
        //   - balance_snapshot_holdings: one row per (snapshot line, security).
        //     The FK targets balance_snapshot_lines(id) — there is already
        //     exactly one line per (snapshot, account) via that table's
        //     UNIQUE(snapshot_id, account_id), so the line id uniquely pins the
        //     (snapshot, account) pair (ARCH review caveat — keep line FK, not
        //     snapshot+account). ON DELETE CASCADE wipes holdings when the line
        //     goes; ON DELETE RESTRICT on security_id blocks deleting a security
        //     still referenced by history. `value` is denormalized (= quantity *
        //     unit_price) so reports stay reproducible without re-fetching, same
        //     rationale as balance_snapshot_lines.value. No Down migration.
        Migration {
            version: 14,
            description: "create balance_securities and balance_snapshot_holdings",
            sql: database::BALANCE_HOLDINGS_SCHEMA,
            kind: MigrationKind::Up,
        },
        // Migration v15 — Bilan détail par titre (Étape 2), account pivot part.
        // Adds the `kind` discriminator that tells whether an account stores a
        // single denormalized value ('simple') or a basket of per-security
        // holdings ('detailed'), plus `detailed_since` — the authoritative pivot
        // date from which detailed entry is expected (revision decision). The
        // backfill stamps kind='detailed' on every account currently linked to a
        // priced category, since those are exactly the ones that gain the
        // per-title breakdown; `detailed_since` stays NULL until the conversion
        // flow (#211) sets it. SQLite allows only one column per ADD COLUMN, so
        // the two ALTERs are separate statements (same multi-ALTER idempotence
        // shape as v12). Re-running is safe: the UPDATE is naturally idempotent.
        Migration {
            version: 15,
            description: "add kind and detailed_since to balance_accounts",
            sql: "ALTER TABLE balance_accounts ADD COLUMN kind TEXT NOT NULL DEFAULT 'simple' \
                  CHECK (kind IN ('simple','detailed')); \
                  ALTER TABLE balance_accounts ADD COLUMN detailed_since DATE; \
                  UPDATE balance_accounts SET kind = 'detailed' \
                    WHERE balance_category_id IN ( \
                      SELECT id FROM balance_categories WHERE kind = 'priced');",
            kind: MigrationKind::Up,
        },
        // Migration v16 — Bilan détail par titre (Étape 2), data conversion part.
        // Converts each existing single-security "priced" account into a
        // `detailed` account holding exactly one position, with zero data loss.
        //
        // Step 1 mints a shared, deduped `balance_securities` row per priced
        // account symbol (normalized UPPER(TRIM), deduped via ON CONFLICT(symbol)
        // DO NOTHING — the symbol UNIQUE is COLLATE NOCASE so case-variants merge).
        // Critically it converts ONLY accounts whose category carries a real
        // `asset_type` (stock|crypto): balance_securities.asset_type is NOT NULL,
        // and a priced account whose category has asset_type IS NULL has no valid
        // routing — it must be left intact.
        //
        // Step 2 mirrors each existing priced line into one holding
        // (quantity/unit_price/value/price_source/price_fetched_at copied verbatim;
        // book_cost stays NULL — no retroactive acquisition cost for historical
        // lines). UNIQUE(snapshot_line_id, security_id) + ON CONFLICT DO NOTHING
        // makes a re-run a strict no-op.
        //
        // Step 3 collapses the now-redundant per-line qty/unit_price to NULL so a
        // detailed account's line keeps only its total `value` (the per-title
        // breakdown lives in holdings). The 🔴 security fix: this NULLing is scoped
        // to `id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings)` — a
        // line that never received a holding (priced-without-asset_type) is never
        // NULLed, so no silent data loss. NULLing BOTH qty+unit_price together
        // preserves balance_snapshot_lines' (both NULL | both NOT NULL) CHECK.
        //
        // The trailing TEMP-table guard asserts the invariant "qty was NULLed ⇒ a
        // holding exists" and ABORTS (CHECK(ok = 1) on an inserted 0) so any breach
        // rolls back the whole v16 transaction. Idempotent: ON CONFLICT no-ops on
        // re-run, and the guard re-passes since every NULLed line still has its
        // holding.
        Migration {
            version: 16,
            description: "convert existing priced accounts to detailed (one security + mirror holding)",
            sql: "INSERT INTO balance_securities (symbol, currency, asset_type) \
                    SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \
                    FROM balance_accounts a \
                    JOIN balance_categories c ON c.id = a.balance_category_id \
                    WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \
                    ON CONFLICT(symbol) DO NOTHING; \
                  INSERT INTO balance_snapshot_holdings \
                    (snapshot_line_id, security_id, quantity, unit_price, value, price_source, price_fetched_at) \
                    SELECT sl.id, s.id, sl.quantity, sl.unit_price, sl.value, sl.price_source, sl.price_fetched_at \
                    FROM balance_snapshot_lines sl \
                    JOIN balance_accounts a ON a.id = sl.account_id \
                    JOIN balance_securities s ON s.symbol = UPPER(TRIM(a.symbol)) \
                    WHERE a.symbol IS NOT NULL AND sl.quantity IS NOT NULL \
                    ON CONFLICT(snapshot_line_id, security_id) DO NOTHING; \
                  UPDATE balance_snapshot_lines \
                    SET quantity = NULL, unit_price = NULL \
                    WHERE quantity IS NOT NULL \
                      AND id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings); \
                  CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \
                  INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
                    SELECT 1 FROM balance_snapshot_lines sl \
                    JOIN balance_accounts a ON a.id = sl.account_id \
                    JOIN balance_categories c ON c.id = a.balance_category_id \
                    WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL AND sl.quantity IS NULL \
                      AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
                  ) THEN 0 ELSE 1 END; \
                  DROP TABLE _v16_guard;",
            kind: MigrationKind::Up,
        },
        // Migration v17 — the full import format on the sources themselves (#323).
        //
        // Until now `import_sources` carried only the mechanical CSV settings
        // (delimiter, encoding, date format, column mapping). The two fields
        // that decide how an amount is READ — `amount_mode` and
        // `sign_convention` — existed only on `import_config_templates`. That
        // asymmetry is the root bug of this chantier: restoring a saved source
        // re-inferred the mode from the mapping and hardcoded
        // `signConvention: "negative_expense"` (`useImportWizard.ts:321-323`),
        // so a source configured with positive expenses silently flipped back
        // on its second import. After v17 both tables carry the same eight
        // format fields and the asymmetry is gone structurally.
        //
        // All four columns are additive and either defaulted or nullable, so
        // the ALTERs are safe on a populated v16 database:
        //   - `amount_mode` / `sign_convention` carry a CHECK, same pattern as
        //     the v15 `balance_accounts.kind`. `amount_mode` admits
        //     'absolute_indicator' from the start: that third mode (absolute
        //     amount + a D/C indicator column) is out of scope today but must
        //     be implementable without another migration, and the constraint
        //     still refuses a corrupted value right now.
        //   - `header_signature` stores the normalized header labels seen at
        //     the last successful import, for drift detection. NULL until an
        //     import records one, and permanently NULL for headerless files.
        //   - `template_id` is a PROVENANCE TAG only, never re-read as format:
        //     the eight columns of the source are authoritative, so editing a
        //     template must not change any linked source. ON DELETE SET NULL
        //     keeps the source and its format intact when a template is deleted.
        //
        // The backfill reproduces EXACTLY the rule the wizard applies on the
        // fly today (`useImportWizard.ts:321` — a mapping carrying
        // `debitAmount` means debit/credit, anything else means single amount),
        // so no existing source changes behaviour on migration. The test is
        // `LIKE '%debitAmount%'` rather than `json_extract` so the migration
        // depends on no JSON1 extension in the bundled SQLite. `sign_convention`
        // is deliberately NOT backfilled: its DEFAULT restores precisely the
        // value the code hardcoded, which is the only past convention that can
        // be inferred — guessing anything else would silently rewrite meaning.
        Migration {
            version: 17,
            description: "add amount_mode, sign_convention, header_signature and template_id to import_sources",
            sql: "ALTER TABLE import_sources ADD COLUMN amount_mode TEXT NOT NULL DEFAULT 'single' \
                  CHECK (amount_mode IN ('single','debit_credit','absolute_indicator')); \
                  ALTER TABLE import_sources ADD COLUMN sign_convention TEXT NOT NULL DEFAULT 'negative_expense' \
                  CHECK (sign_convention IN ('negative_expense','positive_expense')); \
                  ALTER TABLE import_sources ADD COLUMN header_signature TEXT; \
                  ALTER TABLE import_sources ADD COLUMN template_id INTEGER \
                  REFERENCES import_config_templates(id) ON DELETE SET NULL; \
                  UPDATE import_sources SET amount_mode = 'debit_credit' \
                    WHERE column_mapping LIKE '%debitAmount%';",
            kind: MigrationKind::Up,
        },
    ];

    tauri::Builder::default()
        // Single-instance plugin MUST be registered first. With the `deep-link`
        // feature, it forwards `simpl-resultat://` URLs to the running instance
        // so the OAuth2 callback reaches the process that holds the PKCE verifier.
        .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
            if let Some(window) = app.get_webview_window("main") {
                let _ = window.set_focus();
            }
        }))
        .manage(commands::auth_commands::OAuthState {
            code_verifier: Mutex::new(None),
        })
        .plugin(tauri_plugin_opener::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_process::init())
        .plugin(tauri_plugin_deep_link::init())
        .setup(|app| {
            #[cfg(desktop)]
            app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;

            // Register the custom scheme at runtime on Linux (the .desktop file
            // handles it in prod, but register_all is a no-op there and required
            // for AppImage/dev builds).
            #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
            {
                let _ = app.deep_link().register_all();
            }

            // Canonical Tauri v2 pattern: on_open_url fires for both initial-launch
            // URLs and subsequent URLs forwarded by tauri-plugin-single-instance
            // (with the `deep-link` feature).
            let handle = app.handle().clone();
            app.deep_link().on_open_url(move |event| {
                for url in event.urls() {
                    let url_str = url.as_str();
                    let h = handle.clone();
                    if let Some(code) = extract_auth_code(url_str) {
                        tauri::async_runtime::spawn(async move {
                            match commands::handle_auth_callback(h.clone(), code).await {
                                Ok(account) => {
                                    let _ = h.emit("auth-callback-success", &account);
                                }
                                Err(err) => {
                                    let _ = h.emit("auth-callback-error", &err);
                                }
                            }
                        });
                    } else {
                        // No `code` param — likely an OAuth error response. Surface
                        // it to the frontend instead of leaving the UI stuck in
                        // "loading" forever.
                        let err_msg = extract_auth_error(url_str)
                            .unwrap_or_else(|| "OAuth callback did not include a code".to_string());
                        let _ = h.emit("auth-callback-error", &err_msg);
                    }
                }
            });

            Ok(())
        })
        .plugin(
            tauri_plugin_sql::Builder::default()
                .add_migrations("sqlite:simpl_resultat.db", migrations)
                .build(),
        )
        .invoke_handler(tauri::generate_handler![
            commands::scan_import_folder,
            commands::read_file_content,
            commands::hash_file,
            commands::detect_encoding,
            commands::get_file_preview,
            commands::pick_folder,
            commands::pick_save_file,
            commands::pick_import_file,
            commands::write_export_file,
            commands::read_import_file,
            commands::is_file_encrypted,
            commands::load_profiles,
            commands::save_profiles,
            commands::delete_profile_db,
            commands::get_new_profile_init_sql,
            commands::hash_pin,
            commands::verify_pin,
            commands::repair_migrations,
            commands::validate_license_key,
            commands::store_license,
            commands::store_activation_token,
            commands::read_license,
            commands::get_edition,
            commands::get_machine_id,
            commands::check_entitlement,
            commands::activate_machine,
            commands::deactivate_machine,
            commands::list_activated_machines,
            commands::get_activation_status,
            commands::start_oauth,
            commands::refresh_auth_token,
            commands::get_account_info,
            commands::check_subscription_status,
            commands::logout,
            commands::get_token_store_mode,
            commands::send_feedback,
            commands::get_feedback_user_agent,
            commands::ensure_backup_dir,
            commands::get_file_size,
            commands::file_exists,
            commands::compute_account_return,
            commands::fetch_price,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

/// Extract the `code` query parameter from a deep-link callback URL.
/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123")
fn extract_auth_code(url: &str) -> Option<String> {
    extract_query_param(url, "code")
}

/// Extract an OAuth error description from a callback URL. Returns a
/// formatted string combining `error` and `error_description` when present.
fn extract_auth_error(url: &str) -> Option<String> {
    let error = extract_query_param(url, "error")?;
    match extract_query_param(url, "error_description") {
        Some(desc) => Some(format!("{}: {}", error, desc)),
        None => Some(error),
    }
}

fn extract_query_param(url: &str, key: &str) -> Option<String> {
    let url = url.trim();
    if !url.starts_with("simpl-resultat://auth/callback") {
        return None;
    }
    let query = url.split('?').nth(1)?;
    for pair in query.split('&') {
        let mut kv = pair.splitn(2, '=');
        if kv.next()? == key {
            return kv.next().map(|v| {
                urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string())
            });
        }
    }
    None
}

// =============================================================================
// Tests for migration v9 — balance schema
// -----------------------------------------------------------------------------
// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9
// migration) on a fresh in-memory SQLite database and assert that:
//   - the schema applies cleanly (all 5 tables + 7 indexes created)
//   - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1
//   - CHECK constraints reject invalid kind / direction / currency / kind invariants
//   - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) /
//     (transaction_id,account_id) / category key
//   - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT
//     on transaction_id and on category with linked accounts)
//
// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep
// required. The migration v9 SQL is the source of truth; v1-v8 are not
// required here because v9 is additive and only references the existing
// `transactions` table for the FK on balance_account_transfers — we mirror
// that with a minimal `transactions` table for the integration scenarios.
#[cfg(test)]
mod tests {
    use rusqlite::Connection;

    /// Apply the v9 schema on a fresh in-memory DB. Includes a minimal
    /// `transactions` table because balance_account_transfers references it.
    fn fresh_db() -> Connection {
        let conn = Connection::open_in_memory().expect("open in-memory db");
        // FKs must be enabled per-connection in SQLite.
        conn.execute("PRAGMA foreign_keys = ON;", [])
            .expect("enable FKs");
        // Minimal transactions table mirroring the relevant columns the FK
        // references (id is the only column we need at the SQL level).
        conn.execute_batch(
            "CREATE TABLE transactions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date DATE NOT NULL,
                description TEXT NOT NULL,
                amount REAL NOT NULL
            );",
        )
        .expect("create stub transactions table");
        conn.execute_batch(crate::database::BALANCE_SCHEMA)
            .expect("apply BALANCE_SCHEMA");
        conn
    }

    #[test]
    fn migration_v9_applies_cleanly() {
        let conn = fresh_db();
        // 5 expected tables
        let tables: Vec<String> = conn
            .prepare(
                "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'balance_%' ORDER BY name",
            )
            .unwrap()
            .query_map([], |row| row.get::<_, String>(0))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert_eq!(
            tables,
            vec![
                "balance_account_transfers",
                "balance_accounts",
                "balance_categories",
                "balance_snapshot_lines",
                "balance_snapshots",
            ]
        );
        // 7 expected indexes
        let index_count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_balance_%'",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(index_count, 7);
    }

    #[test]
    fn migration_v9_seeds_7_categories() {
        let conn = fresh_db();
        let count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(count, 7);

        let simple_count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_categories WHERE kind = 'simple' AND is_seed = 1",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(simple_count, 5);

        let priced_count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_categories WHERE kind = 'priced' AND is_seed = 1",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(priced_count, 2);

        // Seeded keys are stable
        let stock_kind: String = conn
            .query_row(
                "SELECT kind FROM balance_categories WHERE key = 'stock'",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(stock_kind, "priced");
    }

    #[test]
    fn migration_v9_rejects_non_cad_currency() {
        let conn = fresh_db();
        // 'cash' category exists from seed; try to insert a non-CAD account.
        let result = conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, currency)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'USD account', 'USD')",
            [],
        );
        assert!(
            result.is_err(),
            "CHECK(currency='CAD') should reject 'USD'"
        );
        let err_msg = result.unwrap_err().to_string();
        assert!(err_msg.to_lowercase().contains("check"));
    }

    #[test]
    fn migration_v9_accepts_default_cad_currency() {
        let conn = fresh_db();
        let inserted = conn
            .execute(
                "INSERT INTO balance_accounts (balance_category_id, name)
                 VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
                [],
            )
            .expect("CAD default insert should succeed");
        assert_eq!(inserted, 1);
    }

    #[test]
    fn migration_v9_rejects_invalid_kind() {
        let conn = fresh_db();
        let result = conn.execute(
            "INSERT INTO balance_categories (key, i18n_key, kind) VALUES ('bogus', 'x.bogus', 'unknown')",
            [],
        );
        assert!(result.is_err(), "kind CHECK should reject 'unknown'");
    }

    #[test]
    fn migration_v9_unique_snapshot_date() {
        let conn = fresh_db();
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
            [],
        )
        .unwrap();
        let result = conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
            [],
        );
        assert!(result.is_err(), "UNIQUE(snapshot_date) should reject dup");
    }

    #[test]
    fn migration_v9_kind_invariants_check() {
        let conn = fresh_db();
        // Setup: a snapshot + an account
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
            [],
        )
        .unwrap();
        let snap_id: i64 = conn
            .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
            .unwrap();
        let acct_id: i64 = conn
            .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
            .unwrap();

        // OK: simple kind (quantity + unit_price both NULL)
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
             VALUES (?1, ?2, 1234.56)",
            rusqlite::params![snap_id, acct_id],
        )
        .expect("simple kind row (qty/price both NULL) should be accepted");

        // OK: priced kind (both set) — needs second account on a priced category
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, symbol)
             VALUES ((SELECT id FROM balance_categories WHERE key='stock'), 'AAPL', 'AAPL')",
            [],
        )
        .unwrap();
        let acct2_id: i64 = conn
            .query_row(
                "SELECT id FROM balance_accounts WHERE name='AAPL'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value)
             VALUES (?1, ?2, 10.0, 200.0, 2000.0)",
            rusqlite::params![snap_id, acct2_id],
        )
        .expect("priced kind row (both set) should be accepted");

        // KO: only quantity set, unit_price NULL → CHECK violation
        let bad = conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, value)
             VALUES (?1, ?2, 10.0, 0.0)",
            rusqlite::params![snap_id, acct_id],
        );
        assert!(
            bad.is_err(),
            "kind invariants CHECK should reject (qty set, price NULL)"
        );

        // KO: only unit_price set, quantity NULL → CHECK violation
        let bad2 = conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, unit_price, value)
             VALUES (?1, ?2, 200.0, 0.0)",
            rusqlite::params![snap_id, acct_id],
        );
        assert!(
            bad2.is_err(),
            "kind invariants CHECK should reject (price set, qty NULL)"
        );
    }

    #[test]
    fn migration_v9_unique_snapshot_account_pair() {
        let conn = fresh_db();
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
            [],
        )
        .unwrap();
        let snap_id: i64 = conn
            .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
            .unwrap();
        let acct_id: i64 = conn
            .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
             VALUES (?1, ?2, 100.0)",
            rusqlite::params![snap_id, acct_id],
        )
        .unwrap();
        let dup = conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
             VALUES (?1, ?2, 200.0)",
            rusqlite::params![snap_id, acct_id],
        );
        assert!(dup.is_err(), "UNIQUE(snapshot_id, account_id) should reject dup");
    }

    #[test]
    fn migration_v9_fk_cascade_on_snapshot_delete() {
        let conn = fresh_db();
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
            [],
        )
        .unwrap();
        let snap_id: i64 = conn
            .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
            .unwrap();
        let acct_id: i64 = conn
            .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
             VALUES (?1, ?2, 100.0)",
            rusqlite::params![snap_id, acct_id],
        )
        .unwrap();
        conn.execute(
            "DELETE FROM balance_snapshots WHERE id = ?1",
            rusqlite::params![snap_id],
        )
        .expect("delete snapshot should cascade");
        let remaining: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0))
            .unwrap();
        assert_eq!(remaining, 0, "snapshot delete should cascade lines");
    }

    #[test]
    fn migration_v9_fk_restrict_on_transaction_delete() {
        let conn = fresh_db();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
            [],
        )
        .unwrap();
        let acct_id: i64 = conn
            .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
            .unwrap();
        let tx_id: i64 = conn
            .query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
             VALUES (?1, ?2, 'in')",
            rusqlite::params![acct_id, tx_id],
        )
        .unwrap();

        // Attempting to delete the linked transaction must be rejected (RESTRICT)
        let result = conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]);
        assert!(
            result.is_err(),
            "FK RESTRICT should block deleting linked transaction"
        );

        // Once the transfer is removed, deletion is allowed again
        conn.execute(
            "DELETE FROM balance_account_transfers WHERE transaction_id = ?1",
            rusqlite::params![tx_id],
        )
        .unwrap();
        conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id])
            .expect("after unlink, transaction can be deleted");
    }

    #[test]
    fn migration_v9_fk_restrict_on_category_with_accounts() {
        let conn = fresh_db();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
            [],
        )
        .unwrap();
        // Try to delete the seeded 'cash' category while an account references it
        let result = conn.execute(
            "DELETE FROM balance_categories WHERE key = 'cash'",
            [],
        );
        assert!(
            result.is_err(),
            "FK RESTRICT should block deleting category with linked accounts"
        );
    }

    #[test]
    fn migration_v9_unique_transaction_account_transfer() {
        let conn = fresh_db();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
            [],
        )
        .unwrap();
        let acct_id: i64 = conn
            .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
            .unwrap();
        let tx_id: i64 = conn
            .query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
             VALUES (?1, ?2, 'in')",
            rusqlite::params![acct_id, tx_id],
        )
        .unwrap();
        let dup = conn.execute(
            "INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
             VALUES (?1, ?2, 'out')",
            rusqlite::params![acct_id, tx_id],
        );
        assert!(
            dup.is_err(),
            "UNIQUE(transaction_id, account_id) should reject dup"
        );
    }

    #[test]
    fn migration_v9_seed_idempotent_on_replay() {
        // The migration uses `INSERT OR IGNORE` keyed by `key`, so applying
        // the schema twice on the same DB must not duplicate seeded rows.
        let conn = fresh_db();
        conn.execute_batch(crate::database::BALANCE_SCHEMA)
            .expect("apply BALANCE_SCHEMA twice");
        let count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(count, 7, "seed must remain idempotent on replay");
    }

    // -------------------------------------------------------------------------
    // Issue #144 (Bilan #6) — integration tests on a seeded DB
    // -------------------------------------------------------------------------
    //
    // The previous tests apply BALANCE_SCHEMA on an empty DB. These tests
    // simulate the realistic upgrade path: a profile DB with imported
    // transactions already there gets the v9 migration applied on top, and
    // we verify:
    //   - existing transactions are not affected by the migration (no row
    //     loss, no schema collision),
    //   - link / unlink transfer round-trips on real (non-stub) transaction
    //     ids,
    //   - the FK RESTRICT correctly chains: try to delete a linked
    //     transaction → blocked, unlink → delete succeeds.

    /// Seed a DB with the *full app schema* (transactions + categories +
    /// keywords + suppliers + adjustments + ...) then apply BALANCE_SCHEMA on
    /// top — mirroring what migration v9 does on an existing user profile.
    /// Returns the connection ready for assertions.
    fn seeded_db_with_balance_schema() -> Connection {
        let conn = Connection::open_in_memory().expect("open in-memory db");
        conn.execute("PRAGMA foreign_keys = ON;", [])
            .expect("enable FKs");
        // Apply the full app schema (v1) — we only need the transactions
        // table for the v9 FK, but applying the whole schema verifies that
        // nothing in v9 collides with the existing tables.
        conn.execute_batch(crate::database::SCHEMA)
            .expect("apply v1 SCHEMA");
        // Pre-seed a few transactions to mimic an existing profile (the user
        // already had data when we shipped v9).
        conn.execute_batch(
            "INSERT INTO transactions (date, description, amount) VALUES
                ('2026-01-15', 'Salary deposit', 3500.0),
                ('2026-02-01', 'Wealthsimple contribution', -400.0),
                ('2026-03-15', 'Grocery store', -125.50),
                ('2026-04-01', 'Wealthsimple contribution', -400.0);",
        )
        .expect("seed transactions");
        // Now apply v9 on top — same way the runtime would.
        conn.execute_batch(crate::database::BALANCE_SCHEMA)
            .expect("apply v9 BALANCE_SCHEMA on seeded DB");
        conn
    }

    #[test]
    fn migration_v9_preserves_existing_transactions_on_seeded_db() {
        let conn = seeded_db_with_balance_schema();
        // Existing transactions must be untouched by the migration.
        let count: i64 = conn
            .query_row("SELECT COUNT(*) FROM transactions", [], |row| row.get(0))
            .unwrap();
        assert_eq!(count, 4, "existing transactions must survive the migration");

        // Spot-check one row's content (no silent data mutation).
        let amount: f64 = conn
            .query_row(
                "SELECT amount FROM transactions WHERE description = 'Salary deposit'",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert!(
            (amount - 3500.0).abs() < f64::EPSILON,
            "salary amount must be preserved verbatim"
        );

        // The seeded categories from BALANCE_SCHEMA must coexist with the
        // pre-existing categories table from v1 (different name, no clash).
        let bal_cat_count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(bal_cat_count, 7);
    }

    #[test]
    fn integration_link_unlink_transfer_roundtrip_on_seeded_db() {
        let conn = seeded_db_with_balance_schema();

        // Create a balance account on the seeded 'cash' category.
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')",
            [],
        )
        .unwrap();
        let account_id: i64 = conn
            .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
            .unwrap();

        // Pick the Feb contribution (-$400) — a typical "in" transfer for the
        // Wealthsimple account from the bank perspective.
        let tx_id: i64 = conn
            .query_row(
                "SELECT id FROM transactions WHERE date = '2026-02-01'",
                [],
                |r| r.get(0),
            )
            .unwrap();

        // 1. Link
        let inserted = conn
            .execute(
                "INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes)
                 VALUES (?1, ?2, 'in', 'monthly contribution')",
                rusqlite::params![account_id, tx_id],
            )
            .expect("link succeeds with real transaction id");
        assert_eq!(inserted, 1);

        // 2. Verify the row is queryable through the joined view used by
        //    `listAccountTransfers` in TS.
        let (joined_amount, direction): (f64, String) = conn
            .query_row(
                "SELECT t.amount, bat.direction
                   FROM balance_account_transfers bat
                   JOIN transactions t ON t.id = bat.transaction_id
                  WHERE bat.account_id = ?1",
                rusqlite::params![account_id],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .expect("joined view must read");
        assert!((joined_amount - (-400.0)).abs() < f64::EPSILON);
        assert_eq!(direction, "in");

        // 3. Try to delete the linked transaction — must be blocked (RESTRICT).
        let blocked = conn.execute(
            "DELETE FROM transactions WHERE id = ?1",
            rusqlite::params![tx_id],
        );
        assert!(
            blocked.is_err(),
            "linked transaction deletion must be blocked by FK RESTRICT"
        );

        // 4. Unlink
        let unlinked = conn
            .execute(
                "DELETE FROM balance_account_transfers
                  WHERE account_id = ?1 AND transaction_id = ?2",
                rusqlite::params![account_id, tx_id],
            )
            .expect("unlink succeeds");
        assert_eq!(unlinked, 1);

        // 5. After unlink, deleting the transaction must succeed.
        let allowed = conn
            .execute(
                "DELETE FROM transactions WHERE id = ?1",
                rusqlite::params![tx_id],
            )
            .expect("after unlink, transaction can be deleted");
        assert_eq!(allowed, 1);

        // 6. Sanity: no orphan transfer rows survived.
        let remaining_links: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_account_transfers WHERE transaction_id = ?1",
                rusqlite::params![tx_id],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(remaining_links, 0);
    }

    #[test]
    fn integration_modified_dietz_inputs_read_back_correctly_on_seeded_db() {
        // Reads back the snapshot endpoints + cash flows the way
        // `compute_account_return` does, on a DB that has both v1 transactions
        // and v9 balance tables. Asserts the SQL queries used by
        // `balance_commands.rs::read_value_at_or_before` and `read_cash_flows`
        // return the expected shapes.
        let conn = seeded_db_with_balance_schema();

        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name)
             VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')",
            [],
        )
        .unwrap();
        let account_id: i64 = conn
            .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
            .unwrap();

        // Two snapshot endpoints (V_start, V_end) and one mid-period contribution.
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES
                ('2026-01-01'),
                ('2026-04-01')",
            [],
        )
        .unwrap();
        let s_start: i64 = conn
            .query_row(
                "SELECT id FROM balance_snapshots WHERE snapshot_date='2026-01-01'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        let s_end: i64 = conn
            .query_row(
                "SELECT id FROM balance_snapshots WHERE snapshot_date='2026-04-01'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
             VALUES (?1, ?2, 1000.0)",
            rusqlite::params![s_start, account_id],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
             VALUES (?1, ?2, 1500.0)",
            rusqlite::params![s_end, account_id],
        )
        .unwrap();

        // Link the Feb 1 contribution as an `in` transfer.
        let tx_id: i64 = conn
            .query_row(
                "SELECT id FROM transactions WHERE date='2026-02-01'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
             VALUES (?1, ?2, 'in')",
            rusqlite::params![account_id, tx_id],
        )
        .unwrap();

        // Mirror `read_value_at_or_before` for V_start — exact SQL used in
        // `balance_commands.rs`.
        let v_start: Option<f64> = conn
            .query_row(
                "SELECT l.value
                   FROM balance_snapshot_lines l
                   JOIN balance_snapshots s ON s.id = l.snapshot_id
                  WHERE l.account_id = ?1
                    AND s.snapshot_date <= ?2
                  ORDER BY s.snapshot_date DESC
                  LIMIT 1",
                rusqlite::params![account_id, "2026-01-01"],
                |r| r.get(0),
            )
            .ok();
        assert_eq!(v_start, Some(1000.0));

        // V_end at 2026-04-01 — picks up the second snapshot.
        let v_end: Option<f64> = conn
            .query_row(
                "SELECT l.value
                   FROM balance_snapshot_lines l
                   JOIN balance_snapshots s ON s.id = l.snapshot_id
                  WHERE l.account_id = ?1
                    AND s.snapshot_date <= ?2
                  ORDER BY s.snapshot_date DESC
                  LIMIT 1",
                rusqlite::params![account_id, "2026-04-01"],
                |r| r.get(0),
            )
            .ok();
        assert_eq!(v_end, Some(1500.0));

        // Cash flows in [2026-01-01, 2026-04-01] — exactly one (-400 abs amount → +400 in).
        let mut stmt = conn
            .prepare(
                "SELECT t.date, ABS(t.amount), bat.direction
                   FROM balance_account_transfers bat
                   JOIN transactions t ON t.id = bat.transaction_id
                  WHERE bat.account_id = ?1
                    AND t.date BETWEEN ?2 AND ?3
                  ORDER BY t.date",
            )
            .unwrap();
        let flows: Vec<(String, f64, String)> = stmt
            .query_map(
                rusqlite::params![account_id, "2026-01-01", "2026-04-01"],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
            )
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert_eq!(flows.len(), 1);
        assert_eq!(flows[0].0, "2026-02-01");
        assert!((flows[0].1 - 400.0).abs() < f64::EPSILON);
        assert_eq!(flows[0].2, "in");
    }

    #[test]
    fn integration_v9_preserves_v1_categories_and_keywords() {
        // Defensive: v9 introduces `balance_categories` while v1 already has
        // `categories`. Make sure neither is mistaken for the other and that
        // the v1 seeds (when present) survive the migration cleanly.
        let conn = seeded_db_with_balance_schema();

        // Insert a v1 category + keyword (mimicking v1 seed data already present).
        conn.execute(
            "INSERT INTO categories (id, name, type, color, sort_order)
             VALUES (50, 'Épicerie', 'expense', '#10b981', 50)",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO keywords (keyword, category_id, priority, is_active)
             VALUES ('IGA', 50, 100, 1)",
            [],
        )
        .unwrap();

        // Now insert a v9 category with the SAME numeric id (should be allowed
        // — different table, different namespace).
        conn.execute(
            "INSERT INTO balance_categories (id, key, i18n_key, kind, sort_order)
             VALUES (50, 'mortgage', 'balance.category.mortgage', 'simple', 100)",
            [],
        )
        .expect(
            "balance_categories.id namespace must be independent from categories.id",
        );

        // The v1 row is untouched.
        let v1_name: String = conn
            .query_row(
                "SELECT name FROM categories WHERE id = 50",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(v1_name, "Épicerie");

        // The v9 row is queryable on its own table.
        let v9_key: String = conn
            .query_row(
                "SELECT key FROM balance_categories WHERE id = 50",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(v9_key, "mortgage");
    }

    // =========================================================================
    // Migration v10 — add asset_type to balance_categories
    // -------------------------------------------------------------------------
    // The v10 SQL applies on top of v9 (column add + backfill of the two priced
    // seeds). These tests are statement-equivalent to the production migration:
    // they execute the same SQL string used in `lib.rs`'s Migration array.
    // =========================================================================

    /// Production v10 SQL — kept in sync with the Migration { version: 10 }
    /// entry above. Tests apply this on top of fresh_db() (which already ran v9).
    const V10_SQL: &str = "ALTER TABLE balance_categories ADD COLUMN asset_type TEXT \
                           CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')); \
                           UPDATE balance_categories SET asset_type = 'stock' \
                             WHERE key = 'stock' AND is_seed = 1; \
                           UPDATE balance_categories SET asset_type = 'crypto' \
                             WHERE key = 'crypto' AND is_seed = 1;";

    #[test]
    fn migration_v10_adds_asset_type_column() {
        let conn = fresh_db();
        conn.execute_batch(V10_SQL).expect("apply v10");

        // pragma_table_info should now list `asset_type` for balance_categories.
        let cols: Vec<String> = conn
            .prepare("SELECT name FROM pragma_table_info('balance_categories')")
            .unwrap()
            .query_map([], |row| row.get::<_, String>(0))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert!(
            cols.contains(&"asset_type".to_string()),
            "asset_type column should exist after v10, got {:?}",
            cols
        );
    }

    #[test]
    fn migration_v10_backfills_priced_seeds() {
        let conn = fresh_db();
        conn.execute_batch(V10_SQL).expect("apply v10");

        let stock: String = conn
            .query_row(
                "SELECT asset_type FROM balance_categories WHERE key = 'stock'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(stock, "stock");

        let crypto: String = conn
            .query_row(
                "SELECT asset_type FROM balance_categories WHERE key = 'crypto'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(crypto, "crypto");

        // The 5 simple seeds must remain NULL.
        for key in &["cash", "tfsa", "rrsp", "fund", "other"] {
            let v: Option<String> = conn
                .query_row(
                    "SELECT asset_type FROM balance_categories WHERE key = ?1",
                    [key],
                    |r| r.get(0),
                )
                .unwrap();
            assert!(v.is_none(), "simple seed {} should have NULL asset_type", key);
        }
    }

    #[test]
    fn migration_v10_leaves_custom_priced_rows_null() {
        let conn = fresh_db();
        // Insert a custom priced category BEFORE running v10 — the column
        // doesn't exist yet, so it has no asset_type cell to populate.
        conn.execute(
            "INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
             VALUES ('mining_etf', 'x.mining', 'priced', 100, 0)",
            [],
        )
        .expect("insert custom priced row");

        conn.execute_batch(V10_SQL).expect("apply v10");

        let v: Option<String> = conn
            .query_row(
                "SELECT asset_type FROM balance_categories WHERE key = 'mining_etf'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert!(
            v.is_none(),
            "legacy custom priced rows must stay NULL post-migration (no fetch button until user edits)"
        );
    }

    #[test]
    fn migration_v10_check_rejects_invalid_asset_type() {
        let conn = fresh_db();
        conn.execute_batch(V10_SQL).expect("apply v10");

        let res = conn.execute(
            "INSERT INTO balance_categories (key, i18n_key, kind, asset_type) \
             VALUES ('bogus', 'x', 'priced', 'gold')",
            [],
        );
        assert!(
            res.is_err(),
            "CHECK should reject asset_type values outside ('stock','crypto')"
        );
    }

    // =========================================================================
    // Migration v11 — cleanup orphan balance snapshots (#176)
    // -------------------------------------------------------------------------
    // Validates that the v11 SQL deletes snapshot rows that have no associated
    // lines (left behind by the pre-#176 race) while preserving rows that have
    // at least one line. Statement-equivalent to the production migration.
    // =========================================================================

    /// Production v11 SQL — kept in sync with the Migration { version: 11 }
    /// entry above.
    const V11_SQL: &str = "DELETE FROM balance_snapshots \
                           WHERE NOT EXISTS ( \
                             SELECT 1 FROM balance_snapshot_lines \
                              WHERE snapshot_id = balance_snapshots.id);";

    #[test]
    fn migration_v11_deletes_orphan_snapshots() {
        let conn = fresh_db();
        conn.execute_batch(V10_SQL).expect("apply v10");

        // Seed an orphan: snapshot with NO lines.
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-15')",
            [],
        )
        .unwrap();
        let orphan_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // Seed a healthy snapshot with one line — needs an account first.
        // Use the seeded `cash` simple category from v9.
        let cash_cat_id: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'cash'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Test')",
            [cash_cat_id],
        )
        .unwrap();
        let acc_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-02-15')",
            [],
        )
        .unwrap();
        let healthy_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines \
               (snapshot_id, account_id, value, price_source) \
             VALUES (?1, ?2, 100.0, 'manual')",
            [healthy_id, acc_id],
        )
        .unwrap();

        // Pre-conditions.
        let pre_count: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
            .unwrap();
        assert_eq!(pre_count, 2);

        // Apply v11.
        conn.execute_batch(V11_SQL).expect("apply v11");

        // Orphan gone, healthy preserved.
        let post_count: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
            .unwrap();
        assert_eq!(post_count, 1, "v11 should delete only the orphan");
        let surviving_id: i64 = conn
            .query_row("SELECT id FROM balance_snapshots", [], |r| r.get(0))
            .unwrap();
        assert_eq!(surviving_id, healthy_id);
        // And ensure the orphan id is gone.
        let still_orphan: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_snapshots WHERE id = ?1",
                [orphan_id],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(still_orphan, 0);
    }

    #[test]
    fn migration_v11_is_idempotent_on_clean_db() {
        let conn = fresh_db();
        conn.execute_batch(V10_SQL).expect("apply v10");
        // Empty balance_snapshots — running v11 should be a no-op.
        conn.execute_batch(V11_SQL).expect("apply v11");
        let count: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
            .unwrap();
        assert_eq!(count, 0);
    }

    // =========================================================================
    // Migrations v12 + v13 — Bilan axe véhicule (Étape 1) — issue #202
    // -------------------------------------------------------------------------
    // v12 (additive): adds `vehicle_type` (fiscal envelope — NOT an automobile)
    // to balance_accounts and `custom_label` to balance_categories, backfills
    // the envelope onto ex-tfsa/rrsp accounts (cash stays NULL), and recovers
    // any seed i18n_key that bug I had overwritten with free text.
    // v13 (reclass): re-links the ex-tfsa/rrsp accounts to the `other` asset
    // class and deactivates the two now-deprecated envelope seeds. Conditional
    // + idempotent (no Down migration exists).
    //
    // Both constants are statement-equivalent to the Migration { version: 12/13 }
    // entries in `lib.rs` above (kept in sync by hand, same pattern as V10/V11).
    // =========================================================================

    /// Production v12 SQL — kept in sync with the Migration { version: 12 } entry.
    const V12_SQL: &str = "ALTER TABLE balance_accounts ADD COLUMN vehicle_type TEXT \
                           CHECK(vehicle_type IS NULL OR vehicle_type IN \
                                 ('unregistered','tfsa','rrsp','rrif','fhsa','resp')); \
                           ALTER TABLE balance_categories ADD COLUMN custom_label TEXT; \
                           UPDATE balance_accounts SET vehicle_type = 'tfsa' \
                             WHERE balance_category_id = ( \
                               SELECT id FROM balance_categories WHERE key = 'tfsa'); \
                           UPDATE balance_accounts SET vehicle_type = 'rrsp' \
                             WHERE balance_category_id = ( \
                               SELECT id FROM balance_categories WHERE key = 'rrsp'); \
                           UPDATE balance_categories SET custom_label = i18n_key \
                             WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%'; \
                           UPDATE balance_categories SET i18n_key = 'balance.category.' || key \
                             WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%';";

    /// Production v13 SQL — kept in sync with the Migration { version: 13 } entry.
    const V13_SQL: &str = "UPDATE balance_accounts \
                             SET balance_category_id = ( \
                               SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1) \
                             WHERE balance_category_id IN ( \
                               SELECT id FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_seed = 1) \
                             AND EXISTS ( \
                               SELECT 1 FROM balance_categories WHERE key = 'other' AND is_seed = 1); \
                           UPDATE balance_categories SET is_active = 0 \
                             WHERE key IN ('tfsa','rrsp') AND is_seed = 1;";

    /// Apply the full v9→v11 chain on a fresh DB so v12/v13 tests start from a
    /// realistic post-v11 state (v9 seeds + v10 asset_type + v11 cleanup).
    fn db_through_v11() -> Connection {
        let conn = fresh_db(); // already applied BALANCE_SCHEMA (v9)
        conn.execute_batch(V10_SQL).expect("apply v10");
        conn.execute_batch(V11_SQL).expect("apply v11");
        conn
    }

    #[test]
    fn migration_v12_adds_columns_and_backfills_vehicle() {
        let conn = db_through_v11();

        // Seed accounts across the relevant seed categories BEFORE v12 so the
        // backfill has something to act on.
        for key in &["cash", "tfsa", "rrsp", "other"] {
            conn.execute(
                "INSERT INTO balance_accounts (balance_category_id, name) \
                 VALUES ((SELECT id FROM balance_categories WHERE key = ?1), ?2)",
                rusqlite::params![key, format!("acct-{key}")],
            )
            .unwrap();
        }

        conn.execute_batch(V12_SQL).expect("apply v12");

        // New columns exist.
        let acct_cols: Vec<String> = conn
            .prepare("SELECT name FROM pragma_table_info('balance_accounts')")
            .unwrap()
            .query_map([], |r| r.get::<_, String>(0))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert!(acct_cols.contains(&"vehicle_type".to_string()));
        let cat_cols: Vec<String> = conn
            .prepare("SELECT name FROM pragma_table_info('balance_categories')")
            .unwrap()
            .query_map([], |r| r.get::<_, String>(0))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert!(cat_cols.contains(&"custom_label".to_string()));

        // tfsa / rrsp accounts get the envelope; cash + other stay NULL.
        let read_vehicle = |name: &str| -> Option<String> {
            conn.query_row(
                "SELECT vehicle_type FROM balance_accounts WHERE name = ?1",
                [name],
                |r| r.get(0),
            )
            .unwrap()
        };
        assert_eq!(read_vehicle("acct-tfsa").as_deref(), Some("tfsa"));
        assert_eq!(read_vehicle("acct-rrsp").as_deref(), Some("rrsp"));
        assert!(read_vehicle("acct-cash").is_none(), "cash must stay NULL");
        assert!(read_vehicle("acct-other").is_none(), "other must stay NULL");
    }

    #[test]
    fn migration_v12_defensive_backfill_recovers_overwritten_i18n_key() {
        // Simulate bug I: a seed category whose i18n_key was clobbered by a
        // user-typed free-text label ("Mon CELI perso") instead of the
        // canonical balance.category.tfsa key.
        let conn = db_through_v11();
        conn.execute(
            "UPDATE balance_categories SET i18n_key = 'Mon CELI perso' WHERE key = 'tfsa'",
            [],
        )
        .unwrap();

        conn.execute_batch(V12_SQL).expect("apply v12");

        // The free text is recovered into custom_label, the i18n_key restored.
        let (i18n, custom): (String, Option<String>) = conn
            .query_row(
                "SELECT i18n_key, custom_label FROM balance_categories WHERE key = 'tfsa'",
                [],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert_eq!(i18n, "balance.category.tfsa");
        assert_eq!(custom.as_deref(), Some("Mon CELI perso"));

        // A seed whose i18n_key was already canonical keeps NULL custom_label.
        let cash_custom: Option<String> = conn
            .query_row(
                "SELECT custom_label FROM balance_categories WHERE key = 'cash'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert!(cash_custom.is_none());
    }

    #[test]
    fn migration_v12_check_rejects_invalid_vehicle_type() {
        let conn = db_through_v11();
        conn.execute_batch(V12_SQL).expect("apply v12");
        let res = conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, vehicle_type) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'car')",
            [],
        );
        assert!(
            res.is_err(),
            "CHECK should reject a vehicle_type outside the fiscal enum"
        );
    }

    #[test]
    fn migration_v13_reclasses_accounts_and_deactivates_seeds() {
        let conn = db_through_v11();

        // Two ex-envelope accounts + an archived one, plus a cash account.
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI actif')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER actif')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, is_active, archived_at) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI archivé', 0, CURRENT_TIMESTAMP)",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Chèque')",
            [],
        )
        .unwrap();

        conn.execute_batch(V12_SQL).expect("apply v12");
        conn.execute_batch(V13_SQL).expect("apply v13");

        let other_id: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1",
                [],
                |r| r.get(0),
            )
            .unwrap();

        // Every ex-tfsa/rrsp account (active OR archived) now points to `other`,
        // with the envelope preserved on vehicle_type.
        for (name, vehicle) in &[
            ("CELI actif", "tfsa"),
            ("REER actif", "rrsp"),
            ("CELI archivé", "tfsa"),
        ] {
            let (cat_id, vt): (i64, Option<String>) = conn
                .query_row(
                    "SELECT balance_category_id, vehicle_type FROM balance_accounts WHERE name = ?1",
                    [name],
                    |r| Ok((r.get(0)?, r.get(1)?)),
                )
                .unwrap();
            assert_eq!(cat_id, other_id, "{name} should be re-linked to other");
            assert_eq!(vt.as_deref(), Some(*vehicle), "{name} envelope preserved");
        }

        // The cash account is untouched.
        let cash_cat: i64 = conn
            .query_row(
                "SELECT balance_category_id FROM balance_accounts WHERE name = 'Chèque'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        let cash_seed: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'cash'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(cash_cat, cash_seed);

        // The tfsa/rrsp seeds are deactivated; the asset-class seeds stay active.
        let inactive: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_categories \
                 WHERE key IN ('tfsa','rrsp') AND is_seed = 1 AND is_active = 0",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(inactive, 2, "both envelope seeds must be deactivated");
        let active: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_categories WHERE is_active = 1 AND is_seed = 1",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(active, 5, "5 asset-class seeds remain active");
    }

    #[test]
    fn migration_v9_to_v13_chain_preserves_snapshot_lines_and_transfers() {
        // The headline regression test: run the WHOLE v9→v13 chain on a seeded
        // profile with snapshots + a transfer on an ex-CELI account, and assert
        // the financial history is byte-for-byte preserved across the reclass.
        let conn = seeded_db_with_balance_schema(); // v9 applied + transactions
        conn.execute_batch(V10_SQL).expect("apply v10");
        conn.execute_batch(V11_SQL).expect("apply v11");

        // A CELI account (ex-envelope) with two snapshot endpoints + a linked
        // contribution transfer.
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'Wealthsimple CELI')",
            [],
        )
        .unwrap();
        let acct_id: i64 = conn
            .query_row(
                "SELECT id FROM balance_accounts WHERE name = 'Wealthsimple CELI'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-01'), ('2026-04-01')",
            [],
        )
        .unwrap();
        let s_start: i64 = conn
            .query_row(
                "SELECT id FROM balance_snapshots WHERE snapshot_date = '2026-01-01'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        let s_end: i64 = conn
            .query_row(
                "SELECT id FROM balance_snapshots WHERE snapshot_date = '2026-04-01'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1000.0)",
            rusqlite::params![s_start, acct_id],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1500.0)",
            rusqlite::params![s_end, acct_id],
        )
        .unwrap();
        let tx_id: i64 = conn
            .query_row(
                "SELECT id FROM transactions WHERE date = '2026-02-01'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) \
             VALUES (?1, ?2, 'in')",
            rusqlite::params![acct_id, tx_id],
        )
        .unwrap();

        // Capture the snapshot_lines fingerprint BEFORE the reclass.
        let lines_before: Vec<(i64, i64, f64)> = conn
            .prepare("SELECT snapshot_id, account_id, value FROM balance_snapshot_lines ORDER BY id")
            .unwrap()
            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();

        // Run the reclass chain.
        conn.execute_batch(V12_SQL).expect("apply v12");
        conn.execute_batch(V13_SQL).expect("apply v13");

        // snapshot_lines are byte-for-byte identical (same ids, same values).
        let lines_after: Vec<(i64, i64, f64)> = conn
            .prepare("SELECT snapshot_id, account_id, value FROM balance_snapshot_lines ORDER BY id")
            .unwrap()
            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert_eq!(
            lines_before, lines_after,
            "snapshot_lines must be identical before/after the reclass"
        );

        // The account moved to `other` and kept its CELI envelope.
        let (cat_key, vt): (String, Option<String>) = conn
            .query_row(
                "SELECT c.key, a.vehicle_type \
                   FROM balance_accounts a JOIN balance_categories c ON c.id = a.balance_category_id \
                  WHERE a.id = ?1",
                [acct_id],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert_eq!(cat_key, "other");
        assert_eq!(vt.as_deref(), Some("tfsa"));

        // The transfer link is intact.
        let transfer_count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_account_transfers WHERE account_id = ?1 AND transaction_id = ?2",
                rusqlite::params![acct_id, tx_id],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(transfer_count, 1, "transfer must survive the reclass");
    }

    #[test]
    fn migration_v13_is_idempotent_on_replay() {
        // Running v13 twice must not change anything the second time.
        let conn = db_through_v11();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI')",
            [],
        )
        .unwrap();
        conn.execute_batch(V12_SQL).expect("apply v12");
        conn.execute_batch(V13_SQL).expect("apply v13 #1");
        let other_id: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1",
                [],
                |r| r.get(0),
            )
            .unwrap();
        // Second run: no-op.
        conn.execute_batch(V13_SQL).expect("apply v13 #2");
        let cat_id: i64 = conn
            .query_row(
                "SELECT balance_category_id FROM balance_accounts WHERE name = 'CELI'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(cat_id, other_id, "account stays on other after replay");
    }

    #[test]
    fn migration_v13_guard_noop_when_other_seed_missing() {
        // If the `other` seed is absent (e.g. hard-deleted), v13 must NOT move
        // accounts to a NULL category — the EXISTS guard makes it a clean no-op.
        let conn = db_through_v11();
        // Add a CELI account, then remove the `other` seed (no FK refs to it).
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI')",
            [],
        )
        .unwrap();
        let tfsa_id: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'tfsa'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute("DELETE FROM balance_categories WHERE key = 'other'", [])
            .unwrap();

        conn.execute_batch(V12_SQL).expect("apply v12");
        conn.execute_batch(V13_SQL).expect("apply v13");

        // The account stays on tfsa (no NULL category, no broken FK).
        let cat_id: Option<i64> = conn
            .query_row(
                "SELECT balance_category_id FROM balance_accounts WHERE name = 'CELI'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(
            cat_id,
            Some(tfsa_id),
            "without `other`, the account must not be moved to NULL"
        );
    }

    // =========================================================================
    // Migrations v14 + v15 — Bilan détail par titre (Étape 2) — issue #210
    // -------------------------------------------------------------------------
    // v14 (additive): creates balance_securities (instrument catalogue, symbol
    // normalized + COLLATE NOCASE UNIQUE) and balance_snapshot_holdings (per-
    // security breakdown of a snapshot line; CASCADE on line, RESTRICT on
    // security) + 2 indexes.
    // v15 (account pivot): adds `kind` (simple|detailed) + `detailed_since` to
    // balance_accounts and backfills kind='detailed' on accounts under a priced
    // category. Purely additive; no Down migration exists.
    //
    // V14_SQL is the BALANCE_HOLDINGS_SCHEMA constant (applied verbatim by the
    // Migration { version: 14 } entry). V15_SQL is statement-equivalent to the
    // Migration { version: 15 } entry, kept in sync by hand (same pattern as
    // V10..V13 above).
    // =========================================================================

    /// Production v14 SQL — applied verbatim by Migration { version: 14 }.
    const V14_SQL: &str = crate::database::BALANCE_HOLDINGS_SCHEMA;

    /// Production v15 SQL — kept in sync with the Migration { version: 15 } entry.
    const V15_SQL: &str = "ALTER TABLE balance_accounts ADD COLUMN kind TEXT NOT NULL DEFAULT 'simple' \
                           CHECK (kind IN ('simple','detailed')); \
                           ALTER TABLE balance_accounts ADD COLUMN detailed_since DATE; \
                           UPDATE balance_accounts SET kind = 'detailed' \
                             WHERE balance_category_id IN ( \
                               SELECT id FROM balance_categories WHERE kind = 'priced');";

    /// Apply the full v10→v13 chain on a fresh DB so v14/v15 tests start from a
    /// realistic post-Étape-1 state.
    fn db_through_v13() -> Connection {
        let conn = fresh_db(); // v9 BALANCE_SCHEMA
        conn.execute_batch(V10_SQL).expect("apply v10");
        conn.execute_batch(V11_SQL).expect("apply v11");
        conn.execute_batch(V12_SQL).expect("apply v12");
        conn.execute_batch(V13_SQL).expect("apply v13");
        conn
    }

    #[test]
    fn migration_v14_creates_securities_and_holdings_tables() {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");

        // Both tables exist.
        for table in &["balance_securities", "balance_snapshot_holdings"] {
            let n: i64 = conn
                .query_row(
                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
                    [table],
                    |r| r.get(0),
                )
                .unwrap();
            assert_eq!(n, 1, "{table} must be created by v14");
        }

        // Both indexes exist.
        for idx in &[
            "idx_balance_snapshot_holdings_line",
            "idx_balance_snapshot_holdings_security",
        ] {
            let n: i64 = conn
                .query_row(
                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1",
                    [idx],
                    |r| r.get(0),
                )
                .unwrap();
            assert_eq!(n, 1, "{idx} must be created by v14");
        }
    }

    #[test]
    fn migration_v14_symbol_is_case_insensitive_unique() {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");

        conn.execute(
            "INSERT INTO balance_securities (symbol, asset_type) VALUES ('AAPL', 'stock')",
            [],
        )
        .expect("first insert");

        // A case-variant of the same symbol must collide on the NOCASE UNIQUE.
        let res = conn.execute(
            "INSERT INTO balance_securities (symbol, asset_type) VALUES ('aapl', 'stock')",
            [],
        );
        assert!(
            res.is_err(),
            "COLLATE NOCASE UNIQUE must reject 'aapl' after 'AAPL'"
        );

        // currency defaults to CAD.
        let currency: String = conn
            .query_row(
                "SELECT currency FROM balance_securities WHERE symbol = 'AAPL'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(currency, "CAD");
    }

    #[test]
    fn migration_v14_check_rejects_invalid_asset_type() {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");
        let res = conn.execute(
            "INSERT INTO balance_securities (symbol, asset_type) VALUES ('GLD', 'gold')",
            [],
        );
        assert!(
            res.is_err(),
            "CHECK should reject asset_type outside ('stock','crypto')"
        );
    }

    #[test]
    fn migration_v14_holdings_fk_policies_cascade_and_restrict() {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");

        // Build a snapshot line on a (priced) account so a holding can attach.
        let stock_cat: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'stock'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Courtage')",
            [stock_cat],
        )
        .unwrap();
        let acc_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-01')",
            [],
        )
        .unwrap();
        let snap_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value) \
             VALUES (?1, ?2, 10.0, 50.0, 500.0)",
            rusqlite::params![snap_id, acc_id],
        )
        .unwrap();
        let line_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_securities (symbol, asset_type) VALUES ('AAPL', 'stock')",
            [],
        )
        .unwrap();
        let sec_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_holdings \
               (snapshot_line_id, security_id, quantity, unit_price, value) \
             VALUES (?1, ?2, 10.0, 50.0, 500.0)",
            rusqlite::params![line_id, sec_id],
        )
        .unwrap();

        // RESTRICT: deleting a referenced security must fail.
        let del_sec = conn.execute(
            "DELETE FROM balance_securities WHERE id = ?1",
            [sec_id],
        );
        assert!(
            del_sec.is_err(),
            "ON DELETE RESTRICT must block deleting a referenced security"
        );

        // CASCADE: deleting the snapshot line removes its holdings.
        conn.execute("DELETE FROM balance_snapshot_lines WHERE id = ?1", [line_id])
            .unwrap();
        let holdings_left: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
                [line_id],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(holdings_left, 0, "CASCADE must remove holdings with the line");
    }

    #[test]
    fn migration_v14_holdings_unique_line_security() {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");

        // Minimal line + security.
        let stock_cat: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'stock'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'C')",
            [stock_cat],
        )
        .unwrap();
        let acc_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-02')",
            [],
        )
        .unwrap();
        let snap_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value) \
             VALUES (?1, ?2, 1.0, 1.0, 1.0)",
            rusqlite::params![snap_id, acc_id],
        )
        .unwrap();
        let line_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_securities (symbol, asset_type) VALUES ('BTC', 'crypto')",
            [],
        )
        .unwrap();
        let sec_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_holdings (snapshot_line_id, security_id, quantity, unit_price, value) \
             VALUES (?1, ?2, 1.0, 1.0, 1.0)",
            rusqlite::params![line_id, sec_id],
        )
        .unwrap();
        // Second holding of the same security on the same line must collide.
        let dup = conn.execute(
            "INSERT INTO balance_snapshot_holdings (snapshot_line_id, security_id, quantity, unit_price, value) \
             VALUES (?1, ?2, 2.0, 2.0, 4.0)",
            rusqlite::params![line_id, sec_id],
        );
        assert!(
            dup.is_err(),
            "UNIQUE(snapshot_line_id, security_id) must reject a duplicate holding"
        );
    }

    #[test]
    fn migration_v15_adds_columns_and_backfills_priced_accounts() {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");

        // A priced (stock) account and a simple (cash) account, both pre-v15.
        let stock_cat: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'stock'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        let cash_cat: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'cash'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Courtage')",
            [stock_cat],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Chèque')",
            [cash_cat],
        )
        .unwrap();

        conn.execute_batch(V15_SQL).expect("apply v15");

        // New columns exist.
        let cols: Vec<String> = conn
            .prepare("SELECT name FROM pragma_table_info('balance_accounts')")
            .unwrap()
            .query_map([], |r| r.get::<_, String>(0))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert!(cols.contains(&"kind".to_string()));
        assert!(cols.contains(&"detailed_since".to_string()));

        // Backfill: priced account → detailed; simple account stays simple.
        let read_kind = |name: &str| -> String {
            conn.query_row(
                "SELECT kind FROM balance_accounts WHERE name = ?1",
                [name],
                |r| r.get(0),
            )
            .unwrap()
        };
        assert_eq!(read_kind("Courtage"), "detailed", "priced account → detailed");
        assert_eq!(read_kind("Chèque"), "simple", "cash account stays simple");

        // detailed_since stays NULL for both (pivot set later by #211).
        let ds: Option<String> = conn
            .query_row(
                "SELECT detailed_since FROM balance_accounts WHERE name = 'Courtage'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert!(ds.is_none(), "detailed_since must be NULL until conversion");
    }

    #[test]
    fn migration_v15_check_rejects_invalid_kind() {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");
        conn.execute_batch(V15_SQL).expect("apply v15");
        let res = conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, kind) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'partial')",
            [],
        );
        assert!(
            res.is_err(),
            "CHECK should reject a kind outside ('simple','detailed')"
        );
    }

    #[test]
    fn migration_v15_default_kind_is_simple() {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");
        conn.execute_batch(V15_SQL).expect("apply v15");
        // An account inserted post-v15 without specifying kind defaults to simple.
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'New cash')",
            [],
        )
        .unwrap();
        let kind: String = conn
            .query_row(
                "SELECT kind FROM balance_accounts WHERE name = 'New cash'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(kind, "simple", "kind must default to 'simple'");
    }

    // =========================================================================
    // Migration v16 — Bilan détail par titre (Étape 2) — issue #211
    // -------------------------------------------------------------------------
    // Data conversion: each existing single-security priced account becomes a
    // detailed account with one security + one mirror holding, zero data loss.
    //   - Step 1: one balance_securities per priced account symbol (normalized,
    //     deduped), ONLY for accounts whose category has a real asset_type.
    //   - Step 2: one holding per priced line (qty/price/value/source mirrored).
    //   - Step 3: NULL the per-line qty/unit_price ONLY where a holding now
    //     exists (the 🔴 fix — never NULL a line that got no holding).
    //   - Trailing TEMP-table CHECK(ok = 1) aborts if any NULLed line lacks a
    //     holding, rolling back the whole v16 transaction.
    //
    // V16_SQL is statement-equivalent to the Migration { version: 16 } entry,
    // kept in sync by hand (same pattern as V10..V15 above).
    // =========================================================================

    /// Production v16 SQL — kept in sync with the Migration { version: 16 } entry.
    const V16_SQL: &str = "INSERT INTO balance_securities (symbol, currency, asset_type) \
                             SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \
                             FROM balance_accounts a \
                             JOIN balance_categories c ON c.id = a.balance_category_id \
                             WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \
                             ON CONFLICT(symbol) DO NOTHING; \
                           INSERT INTO balance_snapshot_holdings \
                             (snapshot_line_id, security_id, quantity, unit_price, value, price_source, price_fetched_at) \
                             SELECT sl.id, s.id, sl.quantity, sl.unit_price, sl.value, sl.price_source, sl.price_fetched_at \
                             FROM balance_snapshot_lines sl \
                             JOIN balance_accounts a ON a.id = sl.account_id \
                             JOIN balance_securities s ON s.symbol = UPPER(TRIM(a.symbol)) \
                             WHERE a.symbol IS NOT NULL AND sl.quantity IS NOT NULL \
                             ON CONFLICT(snapshot_line_id, security_id) DO NOTHING; \
                           UPDATE balance_snapshot_lines \
                             SET quantity = NULL, unit_price = NULL \
                             WHERE quantity IS NOT NULL \
                               AND id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings); \
                           CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \
                           INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
                             SELECT 1 FROM balance_snapshot_lines sl \
                             JOIN balance_accounts a ON a.id = sl.account_id \
                             JOIN balance_categories c ON c.id = a.balance_category_id \
                             WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL AND sl.quantity IS NULL \
                               AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
                           ) THEN 0 ELSE 1 END; \
                           DROP TABLE _v16_guard;";

    /// Build a realistic pre-v16 DB: full v10→v15 chain applied, then two priced
    /// accounts seeded — one CONVERTIBLE (seed `stock` category, asset_type set)
    /// and one NON-CONVERTIBLE (custom priced category with asset_type still
    /// NULL) — each with one snapshot line. Returns (conn, convertible_line_id,
    /// non_convertible_line_id).
    fn db_pre_v16() -> (Connection, i64, i64) {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");
        conn.execute_batch(V15_SQL).expect("apply v15");

        // (a) Convertible: seed `stock` category carries asset_type = 'stock' (v10).
        let stock_cat: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'stock'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, symbol, kind) \
             VALUES (?1, 'Courtage AAPL', ' aapl ', 'detailed')",
            [stock_cat],
        )
        .unwrap();
        let acc_a: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // (b) Non-convertible: a custom priced category with NO asset_type.
        conn.execute(
            "INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
             VALUES ('custom_priced', 'custom', 'priced', 80, 0)",
            [],
        )
        .unwrap();
        let custom_cat: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, symbol, kind) \
             VALUES (?1, 'Courtage XYZ', 'XYZ', 'detailed')",
            [custom_cat],
        )
        .unwrap();
        let acc_b: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // One snapshot, one priced line per account.
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-01')",
            [],
        )
        .unwrap();
        let snap_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines \
               (snapshot_id, account_id, quantity, unit_price, value, price_source, price_fetched_at) \
             VALUES (?1, ?2, 10.0, 50.0, 500.0, 'maximus-api', '2026-06-01T12:00:00')",
            rusqlite::params![snap_id, acc_a],
        )
        .unwrap();
        let line_a: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines \
               (snapshot_id, account_id, quantity, unit_price, value, price_source) \
             VALUES (?1, ?2, 3.0, 7.0, 21.0, 'manual')",
            rusqlite::params![snap_id, acc_b],
        )
        .unwrap();
        let line_b: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        (conn, line_a, line_b)
    }

    #[test]
    fn migration_v16_converts_priced_with_asset_type_and_preserves_values() {
        let (conn, line_a, _line_b) = db_pre_v16();
        conn.execute_batch(V16_SQL).expect("apply v16");

        // A security was minted from the normalized symbol (' aapl ' → 'AAPL').
        let sec_id: i64 = conn
            .query_row(
                "SELECT id FROM balance_securities WHERE symbol = 'AAPL'",
                [],
                |r| r.get(0),
            )
            .expect("security AAPL must exist (normalized UPPER(TRIM))");
        let (sym, cur, at): (String, String, String) = conn
            .query_row(
                "SELECT symbol, currency, asset_type FROM balance_securities WHERE id = ?1",
                [sec_id],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
            )
            .unwrap();
        assert_eq!(sym, "AAPL");
        assert_eq!(cur, "CAD");
        assert_eq!(at, "stock");

        // One mirror holding on line_a with the original qty/price/value/source.
        let (h_qty, h_price, h_val, h_src, h_fetched, h_book): (
            f64,
            f64,
            f64,
            String,
            String,
            Option<f64>,
        ) = conn
            .query_row(
                "SELECT quantity, unit_price, value, price_source, price_fetched_at, book_cost \
                 FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
                [line_a],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
            )
            .expect("a holding must mirror the converted line");
        assert_eq!(h_qty, 10.0);
        assert_eq!(h_price, 50.0);
        assert_eq!(h_val, 500.0);
        assert_eq!(h_src, "maximus-api");
        assert_eq!(h_fetched, "2026-06-01T12:00:00");
        assert!(h_book.is_none(), "book_cost stays NULL for historical lines");
        assert_eq!(
            h_qty * h_price,
            h_val,
            "holding value must equal quantity * unit_price"
        );

        // The aggregated line keeps its total value but qty/unit_price are NULLed.
        let (l_qty, l_price, l_val): (Option<f64>, Option<f64>, f64) = conn
            .query_row(
                "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
                [line_a],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
            )
            .unwrap();
        assert!(l_qty.is_none(), "converted line quantity must be NULL");
        assert!(l_price.is_none(), "converted line unit_price must be NULL");
        assert_eq!(l_val, 500.0, "converted line value must be preserved");
    }

    #[test]
    fn migration_v16_leaves_priced_without_asset_type_intact() {
        let (conn, _line_a, line_b) = db_pre_v16();
        conn.execute_batch(V16_SQL).expect("apply v16");

        // No security minted for the symbol 'XYZ' (its category had no asset_type).
        let xyz_secs: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'XYZ'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(xyz_secs, 0, "no security for a priced account without asset_type");

        // No holding attached to line_b.
        let holdings_b: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
                [line_b],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(holdings_b, 0, "non-convertible line must get no holding");

        // line_b is left fully intact — qty/unit_price NOT NULLed (no silent loss).
        let (q, p, v): (Option<f64>, Option<f64>, f64) = conn
            .query_row(
                "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
                [line_b],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
            )
            .unwrap();
        assert_eq!(q, Some(3.0), "intact line keeps its quantity");
        assert_eq!(p, Some(7.0), "intact line keeps its unit_price");
        assert_eq!(v, 21.0, "intact line keeps its value");
    }

    #[test]
    fn migration_v16_leaves_simple_account_with_residual_symbol_intact() {
        // Regression #228: the v16 abort guard keyed on `a.symbol IS NOT NULL`
        // instead of convertibility. A SIMPLE-category account carrying a
        // residual symbol (left by a priced→simple recategorization; AccountForm
        // renders the symbol field unconditionally and updateBalanceAccount
        // preserves it) has quantity-NULL lines by construction. Those satisfied
        // the over-broad guard predicate (symbol NOT NULL AND qty NULL AND no
        // holding) → guard inserts 0 → CHECK(ok = 1) fails → the whole v16
        // migration aborts → the app no longer starts for that profile. The fix
        // scopes the guard to convertible accounts (c.asset_type IS NOT NULL).
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");
        conn.execute_batch(V15_SQL).expect("apply v15");

        // Simple category: kind = 'simple', asset_type NULL by construction.
        conn.execute(
            "INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
             VALUES ('custom_simple', 'custom', 'simple', 90, 0)",
            [],
        )
        .unwrap();
        let simple_cat: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // Account under the simple category, carrying a residual symbol.
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, symbol, kind) \
             VALUES (?1, 'Compte residuel', 'RESID', 'simple')",
            [simple_cat],
        )
        .unwrap();
        let acc: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // One snapshot line with quantity NULL (simple-kind line invariant).
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-01')",
            [],
        )
        .unwrap();
        let snap_id: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines \
               (snapshot_id, account_id, quantity, unit_price, value, price_source) \
             VALUES (?1, ?2, NULL, NULL, 123.0, 'manual')",
            rusqlite::params![snap_id, acc],
        )
        .unwrap();
        let line: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // v16 must apply cleanly. Pre-fix, the over-broad guard flagged this line
        // (symbol NOT NULL, qty NULL, no holding) and aborted the whole migration.
        conn.execute_batch(V16_SQL)
            .expect("v16 must not abort on a simple account with a residual symbol");

        // The residual symbol minted no security (simple category has no
        // asset_type → excluded from the securities INSERT, step 1).
        let resid_secs: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'RESID'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(resid_secs, 0, "no security for a simple account's residual symbol");

        // No holding attached to the line.
        let holdings: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
                [line],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(holdings, 0, "no holding minted for the simple account");

        // The line is untouched (qty/unit_price stay NULL, value preserved).
        let (l_qty, l_price, l_val): (Option<f64>, Option<f64>, f64) = conn
            .query_row(
                "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
                [line],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
            )
            .unwrap();
        assert!(l_qty.is_none(), "simple line quantity stays NULL");
        assert!(l_price.is_none(), "simple line unit_price stays NULL");
        assert_eq!(l_val, 123.0, "simple line value preserved");

        // The account itself is NOT converted: kind stays 'simple', the residual
        // symbol is preserved, and detailed_since stays NULL (criterion: intact).
        let (a_kind, a_symbol, a_detailed_since): (String, Option<String>, Option<String>) = conn
            .query_row(
                "SELECT kind, symbol, detailed_since FROM balance_accounts WHERE id = ?1",
                [acc],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
            )
            .unwrap();
        assert_eq!(a_kind, "simple", "simple account must not be converted to detailed");
        assert_eq!(a_symbol.as_deref(), Some("RESID"), "residual symbol preserved");
        assert!(a_detailed_since.is_none(), "detailed_since stays NULL");
    }

    #[test]
    fn migration_v16_is_idempotent_on_rerun() {
        let (conn, line_a, _line_b) = db_pre_v16();
        conn.execute_batch(V16_SQL).expect("first v16");
        // Re-running must be a strict no-op (ON CONFLICT no-ops, guard re-passes).
        conn.execute_batch(V16_SQL).expect("second v16 (idempotent)");

        let n_secs: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'AAPL'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(n_secs, 1, "re-run must not duplicate the security");
        let n_holdings: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
                [line_a],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(n_holdings, 1, "re-run must not duplicate the holding");
    }

    #[test]
    fn migration_v16_guard_aborts_and_rolls_back_on_injected_failure() {
        // Simulate a buggy v16 where the holdings-insert step is skipped: step 3
        // would NULL a line that got no holding, and the trailing guard must
        // catch the broken invariant and ABORT. We run the corrupted SQL inside
        // an explicit rusqlite transaction that is dropped (→ rolled back) on the
        // returned error — exactly how sqlx rolls back a failed migration. SQLite
        // ABORT resolution only undoes the offending statement, so the whole-
        // migration safety relies on the surrounding transaction being rolled
        // back (which this test asserts).
        let (mut conn, line_a, _line_b) = db_pre_v16();

        // The corrupted batch: step 1 + step 3 + guard, with step 2 (holdings)
        // intentionally removed. NULLing now happens on lines with no holding.
        // The guard carries the same narrowed predicate as production (#228): the
        // abort is driven by the convertible line_a (AAPL, asset_type = 'stock'),
        // NULLed without a holding; line_b (asset_type NULL) no longer trips the
        // guard, but the convertible line alone is enough to prove abort+rollback.
        const V16_CORRUPT: &str = "\
            INSERT INTO balance_securities (symbol, currency, asset_type) \
              SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \
              FROM balance_accounts a \
              JOIN balance_categories c ON c.id = a.balance_category_id \
              WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \
              ON CONFLICT(symbol) DO NOTHING; \
            UPDATE balance_snapshot_lines SET quantity = NULL, unit_price = NULL \
              WHERE quantity IS NOT NULL; \
            CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \
            INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
              SELECT 1 FROM balance_snapshot_lines sl \
              JOIN balance_accounts a ON a.id = sl.account_id \
              JOIN balance_categories c ON c.id = a.balance_category_id \
              WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL AND sl.quantity IS NULL \
                AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
            ) THEN 0 ELSE 1 END; \
            DROP TABLE _v16_guard;";

        {
            let tx = conn.transaction().expect("begin tx");
            let res = tx.execute_batch(V16_CORRUPT);
            assert!(
                res.is_err(),
                "the CHECK(ok = 1) guard must abort when a NULLed line lacks a holding"
            );
            // tx dropped here without commit → ROLLBACK (mirrors sqlx on error).
        }

        // Rollback left the DB at its pre-v16 state: zero holdings, qty intact.
        let total_holdings: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_snapshot_holdings", [], |r| r.get(0))
            .unwrap();
        assert_eq!(total_holdings, 0, "rollback must leave zero holdings");
        let (q, p): (Option<f64>, Option<f64>) = conn
            .query_row(
                "SELECT quantity, unit_price FROM balance_snapshot_lines WHERE id = ?1",
                [line_a],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert_eq!(q, Some(10.0), "rollback must leave quantity intact");
        assert_eq!(p, Some(50.0), "rollback must leave unit_price intact");
        // And no securities were committed either.
        let total_secs: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0))
            .unwrap();
        assert_eq!(total_secs, 0, "rollback must leave zero securities");
    }

    #[test]
    fn migration_v16_full_chain_applies_cleanly() {
        // The whole v10→v16 chain on a fresh DB with no priced accounts is a
        // no-op for v16 and must apply without error (guard passes vacuously).
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");
        conn.execute_batch(V15_SQL).expect("apply v15");
        conn.execute_batch(V16_SQL).expect("apply v16 on empty data");
        let secs: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0))
            .unwrap();
        assert_eq!(secs, 0, "no securities when there are no priced accounts");
    }

    // -------------------------------------------------------------------------
    // Consolidated schema (new profiles) — issue #202
    // -------------------------------------------------------------------------
    // The consolidated schema must initialize a brand-new profile cleanly with
    // the post-Étape-1 shape: 5 asset-class categories, 4 starter accounts (no
    // NULL category_id), and the CELI/REER starters carrying vehicle_type.

    /// Apply the full consolidated schema on a fresh in-memory DB, the same way
    /// `get_new_profile_init_sql` does for a brand-new profile.
    fn consolidated_db() -> Connection {
        let conn = Connection::open_in_memory().expect("open in-memory db");
        conn.execute("PRAGMA foreign_keys = ON;", [])
            .expect("enable FKs");
        conn.execute_batch(crate::database::CONSOLIDATED_SCHEMA)
            .expect("apply consolidated schema");
        conn
    }

    #[test]
    fn consolidated_schema_inits_cleanly_with_5_categories_and_4_starters() {
        let conn = consolidated_db();

        // Exactly 5 active asset-class seeds, no tfsa/rrsp.
        let active_seeds: Vec<String> = conn
            .prepare(
                "SELECT key FROM balance_categories WHERE is_seed = 1 AND is_active = 1 ORDER BY sort_order",
            )
            .unwrap()
            .query_map([], |r| r.get::<_, String>(0))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert_eq!(
            active_seeds,
            vec!["cash", "fund", "other", "stock", "crypto"],
            "new profiles ship exactly 5 asset-class seeds (no envelope categories)"
        );
        let envelope_seeds: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_categories WHERE key IN ('tfsa','rrsp')",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(envelope_seeds, 0, "no tfsa/rrsp category in a new profile");

        // 4 starter accounts, none with a NULL category (#1 review risk).
        let starter_count: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_accounts", [], |r| r.get(0))
            .unwrap();
        assert_eq!(starter_count, 4);
        let null_cat: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_accounts WHERE balance_category_id IS NULL",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(null_cat, 0, "no starter may have a NULL balance_category_id");

        // The CELI / REER starters live in `other` and carry their envelope.
        let other_id: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'other'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        for (name, vehicle) in &[("CELI", "tfsa"), ("REER", "rrsp")] {
            let (cat_id, vt): (i64, Option<String>) = conn
                .query_row(
                    "SELECT balance_category_id, vehicle_type FROM balance_accounts WHERE name = ?1",
                    [name],
                    |r| Ok((r.get(0)?, r.get(1)?)),
                )
                .unwrap();
            assert_eq!(cat_id, other_id, "{name} starter must attach to other");
            assert_eq!(vt.as_deref(), Some(*vehicle), "{name} carries its envelope");
        }

        // The plain starters have a NULL envelope.
        for name in &["Compte chèque", "Compte non-enregistré"] {
            let vt: Option<String> = conn
                .query_row(
                    "SELECT vehicle_type FROM balance_accounts WHERE name = ?1",
                    [name],
                    |r| r.get(0),
                )
                .unwrap();
            assert!(vt.is_none(), "{name} must have a NULL vehicle_type");
        }
    }

    #[test]
    fn consolidated_schema_check_rejects_invalid_vehicle_type() {
        let conn = consolidated_db();
        let res = conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, vehicle_type) \
             VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'truck')",
            [],
        );
        assert!(
            res.is_err(),
            "consolidated CHECK should reject a vehicle_type outside the fiscal enum"
        );
    }

    #[test]
    fn consolidated_schema_has_holdings_tables_and_kind_at_parity() {
        // The consolidated schema (new profiles) must ship the v14 tables and
        // the v15 account columns from the start — parity with the migrations.
        let conn = consolidated_db();

        for table in &["balance_securities", "balance_snapshot_holdings"] {
            let n: i64 = conn
                .query_row(
                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
                    [table],
                    |r| r.get(0),
                )
                .unwrap();
            assert_eq!(n, 1, "new profiles must ship {table}");
        }
        for idx in &[
            "idx_balance_snapshot_holdings_line",
            "idx_balance_snapshot_holdings_security",
        ] {
            let n: i64 = conn
                .query_row(
                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1",
                    [idx],
                    |r| r.get(0),
                )
                .unwrap();
            assert_eq!(n, 1, "new profiles must ship {idx}");
        }

        // The kind/detailed_since columns exist on balance_accounts.
        let cols: Vec<String> = conn
            .prepare("SELECT name FROM pragma_table_info('balance_accounts')")
            .unwrap()
            .query_map([], |r| r.get::<_, String>(0))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        assert!(cols.contains(&"kind".to_string()));
        assert!(cols.contains(&"detailed_since".to_string()));

        // All 4 starters sit under simple asset classes (cash/other), so the
        // v15 backfill is a no-op here — every starter is 'simple'.
        let detailed: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_accounts WHERE kind = 'detailed'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(detailed, 0, "no starter account is under a priced category");

        // The NOCASE UNIQUE on securities is enforced in the consolidated path too.
        conn.execute(
            "INSERT INTO balance_securities (symbol, asset_type) VALUES ('SHOP', 'stock')",
            [],
        )
        .unwrap();
        let dup = conn.execute(
            "INSERT INTO balance_securities (symbol, asset_type) VALUES ('shop', 'stock')",
            [],
        );
        assert!(dup.is_err(), "consolidated securities must reject case-dupes");
    }

    // =========================================================================
    // Étape 2 — cross-cutting regression & integration on a populated DB (#217)
    // -------------------------------------------------------------------------
    // The headline guarantee of the per-security detail feature: a `detailed`
    // account whose holdings sum to V must produce EXACTLY the same date /
    // category / vehicle aggregates as a `simple` account worth V — the
    // aggregated `balance_snapshot_lines.value` is the single source of truth
    // for every SUM() in the service. These tests freeze concrete golden totals
    // and prove the invariant where it actually runs: real SQLite aggregation.
    //
    // Unlike the TS aggregator tests (which drive a mock that returns canned
    // rows and so can only assert SQL shape + bucketing), here `SUM(value)
    // GROUP BY snapshot_date` is executed by SQLite itself — a regression in the
    // aggregation path or in how a detailed line stores its value would surface
    // as a divergence between the detailed-path total and the simple-path total.
    // =========================================================================

    /// Mirror of `getSnapshotTotalsByDate`'s SQL: per-date SUM of every line.
    fn totals_by_date(conn: &Connection) -> Vec<(String, f64)> {
        conn.prepare(
            "SELECT s.snapshot_date, COALESCE(SUM(l.value), 0) AS total \
               FROM balance_snapshots s \
               LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \
              GROUP BY s.snapshot_date \
              ORDER BY s.snapshot_date ASC",
        )
        .unwrap()
        .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)))
        .unwrap()
        .map(|r| r.unwrap())
        .collect()
    }

    /// Mirror of `getSnapshotTotalsByCategoryAndDate`'s SQL.
    fn totals_by_category(conn: &Connection) -> Vec<(String, String, f64)> {
        conn.prepare(
            "SELECT s.snapshot_date, c.key, COALESCE(SUM(l.value), 0) AS total \
               FROM balance_snapshots s \
               INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \
               INNER JOIN balance_accounts a ON a.id = l.account_id \
               INNER JOIN balance_categories c ON c.id = a.balance_category_id \
              GROUP BY s.snapshot_date, c.key \
              ORDER BY s.snapshot_date ASC, c.key ASC",
        )
        .unwrap()
        .query_map([], |r| {
            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, f64>(2)?))
        })
        .unwrap()
        .map(|r| r.unwrap())
        .collect()
    }

    /// Mirror of `getSnapshotTotalsByVehicleAndDate`'s SQL (NULL → 'none').
    fn totals_by_vehicle(conn: &Connection) -> Vec<(String, String, f64)> {
        conn.prepare(
            "SELECT s.snapshot_date, COALESCE(a.vehicle_type, 'none') AS vkey, \
                    COALESCE(SUM(l.value), 0) AS total \
               FROM balance_snapshots s \
               INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \
               INNER JOIN balance_accounts a ON a.id = l.account_id \
              GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none') \
              ORDER BY s.snapshot_date ASC, vkey ASC",
        )
        .unwrap()
        .query_map([], |r| {
            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, f64>(2)?))
        })
        .unwrap()
        .map(|r| r.unwrap())
        .collect()
    }

    fn new_account(
        conn: &Connection,
        cat_key: &str,
        name: &str,
        kind: &str,
        vehicle: Option<&str>,
    ) -> i64 {
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, kind, vehicle_type) \
             VALUES ((SELECT id FROM balance_categories WHERE key = ?1), ?2, ?3, ?4)",
            rusqlite::params![cat_key, name, kind, vehicle],
        )
        .unwrap();
        conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap()
    }

    fn new_snapshot(conn: &Connection, date: &str) -> i64 {
        conn.execute(
            "INSERT INTO balance_snapshots (snapshot_date) VALUES (?1)",
            [date],
        )
        .unwrap();
        conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap()
    }

    /// Insert a SIMPLE aggregated line (qty/price NULL) and return its id.
    fn new_simple_line(conn: &Connection, snap: i64, acc: i64, value: f64) -> i64 {
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value, price_source) \
             VALUES (?1, ?2, ?3, 'manual')",
            rusqlite::params![snap, acc, value],
        )
        .unwrap();
        conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap()
    }

    /// Insert a DETAILED line (aggregated row + N holdings) the way the service
    /// does: the line value is the rounded-cent SUM of the holdings, qty/price on
    /// the line are NULL, and each holding references a minted security. Returns
    /// the line id.
    fn new_detailed_line(
        conn: &Connection,
        snap: i64,
        acc: i64,
        holdings: &[(&str, f64, f64, f64, Option<f64>)], // symbol, qty, price, value, book_cost
    ) -> i64 {
        let total: f64 = holdings.iter().map(|h| h.3).sum();
        conn.execute(
            "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value, price_source) \
             VALUES (?1, ?2, ?3, 'manual')",
            rusqlite::params![snap, acc, total],
        )
        .unwrap();
        let line: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        for (sym, qty, price, value, book) in holdings {
            conn.execute(
                "INSERT INTO balance_securities (symbol, asset_type) VALUES (?1, 'stock') \
                 ON CONFLICT(symbol) DO NOTHING",
                [sym],
            )
            .unwrap();
            let sec: i64 = conn
                .query_row(
                    "SELECT id FROM balance_securities WHERE symbol = ?1",
                    [sym],
                    |r| r.get(0),
                )
                .unwrap();
            conn.execute(
                "INSERT INTO balance_snapshot_holdings \
                   (snapshot_line_id, security_id, quantity, unit_price, value, book_cost) \
                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
                rusqlite::params![line, sec, qty, price, value, book],
            )
            .unwrap();
        }
        line
    }

    #[test]
    fn regression_detailed_account_totals_equal_simple_account_totals() {
        // Two parallel worlds on the SAME schema. World A: a `simple` cash
        // account + a `simple` brokerage account worth 300. World B: the same
        // cash account + a `detailed` brokerage account whose two holdings
        // (100 + 200) sum to 300. The date/category/vehicle aggregates MUST be
        // byte-identical — that equality is the regression guarantee.
        let conn = consolidated_db(); // ships v14/v15-parity schema + 5 classes

        // Shared cash account (simple) in both worlds.
        // World A.
        let cash_a = new_account(&conn, "cash", "Cash A", "simple", None);
        let broker_a = new_account(&conn, "stock", "Broker A simple", "simple", Some("tfsa"));
        let snap_a = new_snapshot(&conn, "2026-01-31");
        new_simple_line(&conn, snap_a, cash_a, 1000.0);
        new_simple_line(&conn, snap_a, broker_a, 300.0);
        let golden_date_a = totals_by_date(&conn);
        let golden_cat_a = totals_by_category(&conn);
        let golden_veh_a = totals_by_vehicle(&conn);

        // FROZEN golden numbers (derived from the simple-account world).
        assert_eq!(golden_date_a, vec![("2026-01-31".to_string(), 1300.0)]);
        assert_eq!(
            golden_cat_a,
            vec![
                ("2026-01-31".to_string(), "cash".to_string(), 1000.0),
                ("2026-01-31".to_string(), "stock".to_string(), 300.0),
            ]
        );
        assert_eq!(
            golden_veh_a,
            vec![
                ("2026-01-31".to_string(), "none".to_string(), 1000.0),
                ("2026-01-31".to_string(), "tfsa".to_string(), 300.0),
            ]
        );

        // World B on a fresh connection: identical structure, detailed broker.
        let conn2 = consolidated_db();
        let cash_b = new_account(&conn2, "cash", "Cash B", "simple", None);
        let broker_b =
            new_account(&conn2, "stock", "Broker B detailed", "detailed", Some("tfsa"));
        let snap_b = new_snapshot(&conn2, "2026-01-31");
        new_simple_line(&conn2, snap_b, cash_b, 1000.0);
        new_detailed_line(
            &conn2,
            snap_b,
            broker_b,
            &[
                ("AAPL", 2.0, 50.0, 100.0, Some(80.0)),
                ("MSFT", 1.0, 200.0, 200.0, Some(150.0)),
            ],
        );

        // The detailed world reproduces the frozen golden numbers EXACTLY.
        assert_eq!(
            totals_by_date(&conn2),
            golden_date_a,
            "per-date total must be identical: a detailed account worth ΣV \
             aggregates exactly like a simple account worth V"
        );
        assert_eq!(
            totals_by_category(&conn2),
            golden_cat_a,
            "per-category total must be identical across simple/detailed paths"
        );
        assert_eq!(
            totals_by_vehicle(&conn2),
            golden_veh_a,
            "per-vehicle total must be identical across simple/detailed paths"
        );

        // Belt-and-braces: the detailed line's stored value equals Σ(holdings).
        let line_val: f64 = conn2
            .query_row(
                "SELECT value FROM balance_snapshot_lines WHERE account_id = ?1",
                [broker_b],
                |r| r.get(0),
            )
            .unwrap();
        let holdings_sum: f64 = conn2
            .query_row(
                "SELECT COALESCE(SUM(h.value), 0) FROM balance_snapshot_holdings h \
                 JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id \
                 WHERE l.account_id = ?1",
                [broker_b],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(line_val, 300.0);
        assert_eq!(
            line_val, holdings_sum,
            "the aggregated line value IS the holdings SUM"
        );
    }

    #[test]
    fn regression_snapshot_delete_cascades_to_holdings() {
        // Deleting a snapshot must remove its lines (CASCADE on snapshot_id) AND,
        // transitively, the per-security holdings of each detailed line (CASCADE
        // on snapshot_line_id). Two-hop cascade, proven end-to-end.
        let conn = consolidated_db();
        let broker = new_account(&conn, "stock", "Broker", "detailed", None);
        let snap = new_snapshot(&conn, "2026-02-28");
        let line = new_detailed_line(
            &conn,
            snap,
            broker,
            &[("AAPL", 1.0, 100.0, 100.0, Some(90.0))],
        );

        // Sanity: one line, one holding before the delete.
        let h_before: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
                [line],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(h_before, 1);

        conn.execute("DELETE FROM balance_snapshots WHERE id = ?1", [snap])
            .expect("snapshot delete should cascade");

        let lines_after: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0))
            .unwrap();
        let holdings_after: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_snapshot_holdings", [], |r| {
                r.get(0)
            })
            .unwrap();
        assert_eq!(lines_after, 0, "snapshot delete must cascade its lines");
        assert_eq!(
            holdings_after, 0,
            "deleting the line must cascade its holdings (two-hop CASCADE)"
        );
        // The security catalogue row is NOT deleted — only the holding link is.
        let secs: i64 = conn
            .query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0))
            .unwrap();
        assert_eq!(secs, 1, "the security itself survives (RESTRICT, not cascade)");
    }

    /// Build a richer pre-v16 DB than `db_pre_v16`: a SIMPLE cash account, a
    /// CONVERTIBLE priced account (seed `stock`, asset_type set, with TWO
    /// snapshots of history), and a NON-CONVERTIBLE priced account (custom
    /// category, asset_type NULL). Returns
    /// (conn, simple_line_s1, simple_line_s2, conv_line_s1, conv_line_s2, nonconv_line).
    fn db_pre_v16_populated() -> (Connection, i64, i64, i64, i64, i64) {
        let conn = db_through_v13();
        conn.execute_batch(V14_SQL).expect("apply v14");
        conn.execute_batch(V15_SQL).expect("apply v15");

        // Simple cash account.
        let cash: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'cash'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Encaisse')",
            [cash],
        )
        .unwrap();
        let acc_cash: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // Convertible priced account (v15 backfilled it to 'detailed').
        let stock_cat: i64 = conn
            .query_row(
                "SELECT id FROM balance_categories WHERE key = 'stock'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, symbol) \
             VALUES (?1, 'Courtage VEQT', ' veqt.to ')",
            [stock_cat],
        )
        .unwrap();
        let acc_conv: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // Non-convertible: custom priced category, asset_type NULL.
        conn.execute(
            "INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
             VALUES ('custom_priced', 'custom', 'priced', 80, 0)",
            [],
        )
        .unwrap();
        let custom_cat: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_accounts (balance_category_id, name, symbol) \
             VALUES (?1, 'Fonds maison', 'XYZ')",
            [custom_cat],
        )
        .unwrap();
        let acc_nonconv: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // Two snapshots of history (multi-snapshot requirement).
        let s1 = new_snapshot(&conn, "2026-01-31");
        let s2 = new_snapshot(&conn, "2026-02-28");

        // Simple cash lines.
        let scl1 = new_simple_line(&conn, s1, acc_cash, 1000.0);
        let scl2 = new_simple_line(&conn, s2, acc_cash, 1100.0);

        // Convertible priced lines (qty/price set — pre-v16 priced shape).
        conn.execute(
            "INSERT INTO balance_snapshot_lines \
               (snapshot_id, account_id, quantity, unit_price, value, price_source) \
             VALUES (?1, ?2, 10.0, 30.0, 300.0, 'maximus-api')",
            rusqlite::params![s1, acc_conv],
        )
        .unwrap();
        let conv1: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();
        conn.execute(
            "INSERT INTO balance_snapshot_lines \
               (snapshot_id, account_id, quantity, unit_price, value, price_source) \
             VALUES (?1, ?2, 12.0, 30.0, 360.0, 'maximus-api')",
            rusqlite::params![s2, acc_conv],
        )
        .unwrap();
        let conv2: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        // Non-convertible priced line (only at s2, qty/price set).
        conn.execute(
            "INSERT INTO balance_snapshot_lines \
               (snapshot_id, account_id, quantity, unit_price, value, price_source) \
             VALUES (?1, ?2, 5.0, 8.0, 40.0, 'manual')",
            rusqlite::params![s2, acc_nonconv],
        )
        .unwrap();
        let nonconv: i64 = conn
            .query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
            .unwrap();

        let _ = (scl1, scl2);
        (conn, scl1, scl2, conv1, conv2, nonconv)
    }

    #[test]
    fn migration_v14_to_v16_on_populated_db_preserves_integrity_and_totals() {
        // The realistic multi-type integrity case (#217): run v16 on a DB with a
        // simple account, a convertible priced account (2 snapshots of history),
        // and a non-convertible priced account. Freeze the per-snapshot totals
        // BEFORE the migration and assert they're byte-identical AFTER — the
        // conversion to per-security holdings must not move a single cent.
        let (conn, _scl1, _scl2, conv1, conv2, nonconv) = db_pre_v16_populated();

        // Golden totals captured on the pre-v16 DB.
        let date_before = totals_by_date(&conn);
        let cat_before = totals_by_category(&conn);
        let veh_before = totals_by_vehicle(&conn);

        // Concrete frozen numbers (cash 1000/1100, conv 300/360, nonconv 40@s2).
        assert_eq!(
            date_before,
            vec![
                ("2026-01-31".to_string(), 1300.0), // 1000 + 300
                ("2026-02-28".to_string(), 1500.0), // 1100 + 360 + 40
            ]
        );

        // Apply the conversion.
        conn.execute_batch(V16_SQL).expect("apply v16");

        // 1) Aggregates are byte-for-byte identical — no value moved.
        assert_eq!(
            totals_by_date(&conn),
            date_before,
            "v16 must preserve every per-date total"
        );
        assert_eq!(
            totals_by_category(&conn),
            cat_before,
            "v16 must preserve every per-category total"
        );
        assert_eq!(
            totals_by_vehicle(&conn),
            veh_before,
            "v16 must preserve every per-vehicle total"
        );

        // 2) Both convertible lines are NULLed on qty/price but keep their value,
        //    and each mirrors exactly one holding.
        for (line, qty, price, value) in
            [(conv1, 10.0, 30.0, 300.0), (conv2, 12.0, 30.0, 360.0)]
        {
            let (lq, lp, lv): (Option<f64>, Option<f64>, f64) = conn
                .query_row(
                    "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
                    [line],
                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
                )
                .unwrap();
            assert!(lq.is_none(), "converted line {line} qty must be NULL");
            assert!(lp.is_none(), "converted line {line} price must be NULL");
            assert_eq!(lv, value, "converted line {line} value preserved");
            let (hq, hp, hv): (f64, f64, f64) = conn
                .query_row(
                    "SELECT quantity, unit_price, value FROM balance_snapshot_holdings \
                     WHERE snapshot_line_id = ?1",
                    [line],
                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
                )
                .expect("a holding must mirror each converted line");
            assert_eq!((hq, hp, hv), (qty, price, value));
        }

        // 3) Exactly ONE security minted (VEQT.TO, normalized), shared across
        //    both snapshots' holdings — multi-snapshot history collapses to one
        //    catalogue row.
        let veqt_secs: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'VEQT.TO'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(veqt_secs, 1, "one normalized security for the whole history");
        let distinct_secs_on_conv: i64 = conn
            .query_row(
                "SELECT COUNT(DISTINCT h.security_id) FROM balance_snapshot_holdings h \
                 WHERE h.snapshot_line_id IN (?1, ?2)",
                rusqlite::params![conv1, conv2],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(distinct_secs_on_conv, 1, "both snapshots share one security");

        // 4) The non-convertible line is FULLY intact — no holding, qty/price kept.
        let nonconv_holdings: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
                [nonconv],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(nonconv_holdings, 0, "non-convertible line gets no holding");
        let (nq, np, nv): (Option<f64>, Option<f64>, f64) = conn
            .query_row(
                "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
                [nonconv],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
            )
            .unwrap();
        assert_eq!((nq, np, nv), (Some(5.0), Some(8.0), 40.0));

        // 5) No security for the non-convertible symbol.
        let xyz_secs: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'XYZ'",
                [],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(xyz_secs, 0, "no security for a priced account without asset_type");
    }

    // =========================================================================
    // Migration v17 — the full import format on import_sources (#323)
    // -------------------------------------------------------------------------
    // What these tests guarantee:
    //   - v17 applies on a POPULATED v16 database with zero loss: every existing
    //     source keeps its identity and its mechanical CSV settings, and the
    //     child rows that reference it survive the ALTERs.
    //   - the backfill reproduces EXACTLY the rule the wizard applied on the fly
    //     (`useImportWizard.ts:321`), so no source changes behaviour on
    //     migration — a mapping carrying `debitAmount` becomes 'debit_credit',
    //     everything else stays 'single', and `sign_convention` lands on the
    //     value the code used to hardcode.
    //   - the two CHECKs really bite (an unknown enum value is refused, and
    //     'absolute_indicator' is already admitted so the third amount mode
    //     needs no further migration).
    //   - `template_id` is a provenance tag: deleting the template it points at
    //     leaves the source alive with a NULL tag, and editing a template
    //     changes no linked source's format.
    //   - consolidated_schema.sql and the v1→v17 chain agree column for column,
    //     DEFAULT for DEFAULT, CHECK for CHECK, FK for FK.
    //
    // "v1..v16 unchanged" is NOT asserted here: no checksum harness exists in
    // this crate (V10_SQL..V17_SQL are hand-kept copies, and the real checksums
    // only live at runtime in `_sqlx_migrations`). It is verified by the diff —
    // v17 adds strictly new lines and touches no earlier migration string.
    // =========================================================================

    /// Production v3 SQL — kept in sync with the Migration { version: 3 } entry.
    const V3_SQL: &str =
        "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;";

    /// Production v5 SQL — kept in sync with the Migration { version: 5 } entry.
    const V5_SQL: &str = "CREATE TABLE IF NOT EXISTS import_config_templates (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL UNIQUE,
                delimiter TEXT NOT NULL DEFAULT ';',
                encoding TEXT NOT NULL DEFAULT 'utf-8',
                date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
                skip_lines INTEGER NOT NULL DEFAULT 0,
                has_header INTEGER NOT NULL DEFAULT 1,
                column_mapping TEXT NOT NULL,
                amount_mode TEXT NOT NULL DEFAULT 'single',
                sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
            );";

    /// Production v17 SQL — kept in sync with the Migration { version: 17 } entry.
    const V17_SQL: &str = "ALTER TABLE import_sources ADD COLUMN amount_mode TEXT NOT NULL DEFAULT 'single' \
                           CHECK (amount_mode IN ('single','debit_credit','absolute_indicator')); \
                           ALTER TABLE import_sources ADD COLUMN sign_convention TEXT NOT NULL DEFAULT 'negative_expense' \
                           CHECK (sign_convention IN ('negative_expense','positive_expense')); \
                           ALTER TABLE import_sources ADD COLUMN header_signature TEXT; \
                           ALTER TABLE import_sources ADD COLUMN template_id INTEGER \
                           REFERENCES import_config_templates(id) ON DELETE SET NULL; \
                           UPDATE import_sources SET amount_mode = 'debit_credit' \
                             WHERE column_mapping LIKE '%debitAmount%';";

    /// Build the pre-v17 state of the two tables v17 touches. `import_sources`
    /// and `import_config_templates` are shaped by exactly three migrations —
    /// v1 (creation), v3 (`has_header`) and v5 (the templates table). None of
    /// v2, v4 and v6→v16 alters either table, so this IS their v16 shape; the
    /// parity test below re-proves it by comparing the result against the
    /// consolidated reference definition.
    fn db_pre_v17() -> Connection {
        let conn = Connection::open_in_memory().expect("open in-memory db");
        conn.execute("PRAGMA foreign_keys = ON;", [])
            .expect("enable FKs");
        conn.execute_batch(crate::database::SCHEMA)
            .expect("apply v1 SCHEMA");
        conn.execute_batch(V3_SQL).expect("apply v3");
        conn.execute_batch(V5_SQL).expect("apply v5");
        conn
    }

    /// Insert a source and return its id. Only `name` and `column_mapping` have
    /// no default at v16, so everything else is left to the schema.
    fn seed_source(conn: &Connection, name: &str, mapping: &str) -> i64 {
        conn.execute(
            "INSERT INTO import_sources (name, column_mapping) VALUES (?1, ?2)",
            rusqlite::params![name, mapping],
        )
        .unwrap();
        conn.last_insert_rowid()
    }

    /// (name, type, notnull, dflt_value) for every column of `table`, sorted by
    /// name. Sorting drops physical column order on purpose: ALTER TABLE can
    /// only append, while a CREATE TABLE places a column where it reads best —
    /// the order is not part of the contract, the definitions are.
    fn column_shape(conn: &Connection, table: &str) -> Vec<(String, String, i64, Option<String>)> {
        let mut cols: Vec<(String, String, i64, Option<String>)> = conn
            .prepare(&format!(
                "SELECT name, type, \"notnull\", dflt_value FROM pragma_table_info('{table}')"
            ))
            .unwrap()
            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        cols.sort();
        cols
    }

    /// (referenced table, from column, to column, ON DELETE action) per FK.
    fn fk_shape(conn: &Connection, table: &str) -> Vec<(String, String, String, String)> {
        let mut fks: Vec<(String, String, String, String)> = conn
            .prepare(&format!(
                "SELECT \"table\", \"from\", \"to\", on_delete FROM pragma_foreign_key_list('{table}')"
            ))
            .unwrap()
            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
            .unwrap()
            .map(|r| r.unwrap())
            .collect();
        fks.sort();
        fks
    }

    #[test]
    fn migration_v17_applies_on_a_populated_v16_db() {
        let conn = db_pre_v17();

        // A realistic populated v16 profile: a configured source, a template and
        // an imported file hanging off the source by FK.
        let src = seed_source(
            &conn,
            "Desjardins",
            r#"{"date":"Date","description":"Description","amount":"Montant"}"#,
        );
        conn.execute(
            "UPDATE import_sources SET delimiter = ',', encoding = 'windows-1252', \
             date_format = '%Y-%m-%d', skip_lines = 3, has_header = 0 WHERE id = ?1",
            [src],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO import_config_templates (name, column_mapping) VALUES ('Modèle A', '{}')",
            [],
        )
        .unwrap();
        conn.execute(
            "INSERT INTO imported_files (source_id, filename, file_hash) \
             VALUES (?1, 'janvier.csv', 'deadbeef')",
            [src],
        )
        .unwrap();

        conn.execute_batch(V17_SQL).expect("apply v17 on a v16 db");

        // The four columns landed.
        let cols: Vec<String> = column_shape(&conn, "import_sources")
            .into_iter()
            .map(|(n, _, _, _)| n)
            .collect();
        for expected in &[
            "amount_mode",
            "sign_convention",
            "header_signature",
            "template_id",
        ] {
            assert!(
                cols.contains(&expected.to_string()),
                "v17 must add {expected} to import_sources"
            );
        }

        // Nothing the source already carried was touched.
        let (name, delim, enc, fmt, skip, header, mapping): (
            String,
            String,
            String,
            String,
            i64,
            i64,
            String,
        ) = conn
            .query_row(
                "SELECT name, delimiter, encoding, date_format, skip_lines, has_header, \
                 column_mapping FROM import_sources WHERE id = ?1",
                [src],
                |r| {
                    Ok((
                        r.get(0)?,
                        r.get(1)?,
                        r.get(2)?,
                        r.get(3)?,
                        r.get(4)?,
                        r.get(5)?,
                        r.get(6)?,
                    ))
                },
            )
            .unwrap();
        assert_eq!(name, "Desjardins");
        assert_eq!(delim, ",");
        assert_eq!(enc, "windows-1252");
        assert_eq!(fmt, "%Y-%m-%d");
        assert_eq!(skip, 3);
        assert_eq!(header, 0);
        assert_eq!(
            mapping,
            r#"{"date":"Date","description":"Description","amount":"Montant"}"#
        );

        // The new columns land on their defaults: a source that never carried a
        // format now carries the exact one the code applied to it.
        let (mode, sign, sig, tpl): (String, String, Option<String>, Option<i64>) = conn
            .query_row(
                "SELECT amount_mode, sign_convention, header_signature, template_id \
                 FROM import_sources WHERE id = ?1",
                [src],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
            )
            .unwrap();
        assert_eq!(mode, "single");
        assert_eq!(sign, "negative_expense");
        assert!(sig.is_none(), "no header signature until an import records one");
        assert!(tpl.is_none(), "an existing source has no known provenance");

        // The child row and its FK survived the ALTERs.
        let files: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM imported_files WHERE source_id = ?1",
                [src],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(files, 1, "imported_files must survive the v17 ALTERs");
        let templates: i64 = conn
            .query_row("SELECT COUNT(*) FROM import_config_templates", [], |r| {
                r.get(0)
            })
            .unwrap();
        assert_eq!(templates, 1, "templates are untouched by v17");
    }

    #[test]
    fn migration_v17_backfill_reproduces_the_wizard_rule() {
        let conn = db_pre_v17();

        // The rule being frozen (`useImportWizard.ts:321`):
        //   mapping.debitAmount !== undefined ? "debit_credit" : "single"
        let cases: &[(&str, &str, &str)] = &[
            (
                "Deux colonnes",
                r#"{"date":"Date","description":"Libellé","debitAmount":"Débit","creditAmount":"Crédit"}"#,
                "debit_credit",
            ),
            (
                "Débit seul",
                r#"{"date":"Date","description":"Libellé","debitAmount":"Retrait"}"#,
                "debit_credit",
            ),
            (
                "Montant unique",
                r#"{"date":"Date","description":"Libellé","amount":"Montant"}"#,
                "single",
            ),
            (
                "Crédit seul",
                r#"{"date":"Date","description":"Libellé","creditAmount":"Dépôt"}"#,
                "single",
            ),
            (
                "Mapping minimal",
                r#"{"date":"Date","description":"Libellé"}"#,
                "single",
            ),
        ];
        for (name, mapping, _) in cases {
            seed_source(&conn, name, mapping);
        }

        conn.execute_batch(V17_SQL).expect("apply v17");

        for (name, _, expected_mode) in cases {
            let (mode, sign): (String, String) = conn
                .query_row(
                    "SELECT amount_mode, sign_convention FROM import_sources WHERE name = ?1",
                    [name],
                    |r| Ok((r.get(0)?, r.get(1)?)),
                )
                .unwrap();
            assert_eq!(
                mode, *expected_mode,
                "{name}: the backfill must reproduce the wizard rule"
            );
            // The wizard hardcoded this on every restore, so every migrated
            // source must land on it — that is what "no behaviour change" means.
            assert_eq!(
                sign, "negative_expense",
                "{name}: sign_convention restores the previously hardcoded value"
            );
        }
    }

    #[test]
    fn migration_v17_checks_reject_unknown_enum_values() {
        let conn = db_pre_v17();
        conn.execute_batch(V17_SQL).expect("apply v17");

        let set = |col: &str, val: &str| {
            conn.execute(
                &format!("INSERT INTO import_sources (name, column_mapping, {col}) VALUES (?1, '{{}}', ?2)"),
                rusqlite::params![format!("{col}-{val}"), val],
            )
        };

        // The third amount mode is admitted from the start: implementing it later
        // must not require another migration.
        assert!(
            set("amount_mode", "absolute_indicator").is_ok(),
            "absolute_indicator must be accepted by the v17 CHECK"
        );
        assert!(set("amount_mode", "debit_credit").is_ok());
        assert!(set("sign_convention", "positive_expense").is_ok());

        // A corrupted value — e.g. restored from a hand-edited SREF backup — is
        // refused by the database rather than silently mis-read at parse time.
        assert!(
            set("amount_mode", "montants_bizarres").is_err(),
            "an unknown amount_mode must be refused"
        );
        assert!(
            set("sign_convention", "whatever").is_err(),
            "an unknown sign_convention must be refused"
        );
    }

    #[test]
    fn migration_v17_template_id_is_a_nullable_provenance_tag() {
        let conn = db_pre_v17();
        conn.execute_batch(V17_SQL).expect("apply v17");

        conn.execute(
            "INSERT INTO import_config_templates (name, column_mapping, amount_mode, sign_convention) \
             VALUES ('Desjardins', '{}', 'single', 'positive_expense')",
            [],
        )
        .unwrap();
        let tpl = conn.last_insert_rowid();
        let src = seed_source(&conn, "Compte chèque", "{}");
        conn.execute(
            "UPDATE import_sources SET template_id = ?1, sign_convention = 'positive_expense' \
             WHERE id = ?2",
            [tpl, src],
        )
        .unwrap();

        // Editing the template changes NO linked source: the eight format columns
        // on the source are authoritative, the tag only records provenance.
        conn.execute(
            "UPDATE import_config_templates SET amount_mode = 'debit_credit', \
             sign_convention = 'negative_expense' WHERE id = ?1",
            [tpl],
        )
        .unwrap();
        let (mode, sign): (String, String) = conn
            .query_row(
                "SELECT amount_mode, sign_convention FROM import_sources WHERE id = ?1",
                [src],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert_eq!(mode, "single", "editing a template must not touch a source");
        assert_eq!(sign, "positive_expense");

        // Deleting the template keeps the source and its format — only the tag
        // goes (ON DELETE SET NULL).
        conn.execute("DELETE FROM import_config_templates WHERE id = ?1", [tpl])
            .unwrap();
        let (tag, sign_after): (Option<i64>, String) = conn
            .query_row(
                "SELECT template_id, sign_convention FROM import_sources WHERE id = ?1",
                [src],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert!(tag.is_none(), "deleting a template nulls the provenance tag");
        assert_eq!(
            sign_after, "positive_expense",
            "deleting a template must not alter the source format"
        );

        // A dangling tag is refused outright.
        assert!(
            conn.execute(
                "UPDATE import_sources SET template_id = 9999 WHERE id = ?1",
                [src]
            )
            .is_err(),
            "template_id must point at a real template"
        );
    }

    #[test]
    fn consolidated_schema_matches_v17_chain_on_import_sources_at_parity() {
        // The consolidated schema is the tested reference definition for new
        // profiles. Without this test the DEFAULT or the CHECK of the four v17
        // columns could drift between the two definitions unnoticed — the
        // columns are inert on the production path (this script runs after every
        // migration and only uses CREATE TABLE IF NOT EXISTS), so nothing else
        // would surface the divergence.
        let chain = db_pre_v17();
        chain.execute_batch(V17_SQL).expect("apply v17");
        let consolidated = consolidated_db();

        // Same columns, same types, same NOT NULL flags, same DEFAULTs.
        assert_eq!(
            column_shape(&consolidated, "import_sources"),
            column_shape(&chain, "import_sources"),
            "consolidated import_sources must match the v1→v17 chain"
        );
        // Same FK, same ON DELETE action.
        assert_eq!(
            fk_shape(&consolidated, "import_sources"),
            fk_shape(&chain, "import_sources"),
            "consolidated import_sources must carry the same template_id FK"
        );
        assert_eq!(
            fk_shape(&consolidated, "import_sources"),
            vec![(
                "import_config_templates".to_string(),
                "template_id".to_string(),
                "id".to_string(),
                "SET NULL".to_string(),
            )],
        );

        // The CHECKs are not exposed by pragma, so prove them behaviourally on
        // BOTH definitions — the same values must be accepted and refused.
        for conn in [&consolidated, &chain] {
            for (col, val, ok) in [
                ("amount_mode", "absolute_indicator", true),
                ("amount_mode", "debit_credit", true),
                ("amount_mode", "nope", false),
                ("sign_convention", "positive_expense", true),
                ("sign_convention", "nope", false),
            ] {
                let res = conn.execute(
                    &format!(
                        "INSERT INTO import_sources (name, column_mapping, {col}) \
                         VALUES (?1, '{{}}', ?2)"
                    ),
                    rusqlite::params![format!("{col}-{val}"), val],
                );
                assert_eq!(
                    res.is_ok(),
                    ok,
                    "{col} = {val} must behave identically in both definitions"
                );
            }
        }
    }
}

