//! Tauri `app_data_dir` migration across the magnotia → lumotia bundle
//! identifier change.
//!
//! Tauri 2 keys `app_data_dir` and the webview's data store (localStorage,
//! IndexedDB, cookies, service worker storage, cache) plus all Tauri
//! plugin state files (window-state geometry, autostart enable flag) by
//! the bundle identifier. Commit `14313cf` changed the identifier from
//! `uk.co.corbel.magnotia` to `consulting.corbel.lumotia`, which silently
//! orphaned every existing user's webview state on first launch under
//! the new identifier. The JS-side `migrateLocalStorageKey` helper added
//! in `1608109` ran against an empty store and no-op'd.
//!
//! This module resolves the OLD identifier-keyed `app_data_dir` from
//! platform conventions, then recursively copies it to the NEW path via
//! an atomic staging rename. The legacy directory is preserved as a
//! backup. Idempotent: on the second launch the new path already exists
//! and nothing is touched.
//!
//! The hand-rolled `crates/core/src/paths.rs` migration that handles the
//! `~/.local/share/magnotia` → `~/.local/share/lumotia` data dir
//! (SQLite DB, models, recordings) is unrelated and remains the source
//! of truth for the user's transcripts. The two migrations target
//! different directories on disk.

use std::path::{Path, PathBuf};

use lumotia_core::paths::copy_dir_recursive;

/// Bundle identifier used before the magnotia → lumotia rebrand
/// (commit `14313cf`).
pub const OLD_BUNDLE_ID: &str = "uk.co.corbel.magnotia";

/// Bundle identifier in use post-rebrand. MUST match the `identifier`
/// field in `src-tauri/tauri.conf.json` — if those two ever drift,
/// the pre-runtime migration would copy data into a path Tauri never
/// looks at. There is no compile-time check for this invariant;
/// reviewer's job to catch a tauri.conf.json edit that doesn't update
/// this const.
pub const NEW_BUNDLE_ID: &str = "consulting.corbel.lumotia";

/// Outcome of an `app_data_dir` migration attempt. Mostly informational
/// for the setup-hook tracing layer; the function never aborts startup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppDataMigrationStatus {
    /// Legacy path does not exist — fresh install or already migrated
    /// and the legacy backup has been deleted by the user.
    NoLegacyFound,
    /// Both legacy and new paths exist. This is also the steady state
    /// after the first successful migration (we preserve legacy as a
    /// backup), so the warning is informational rather than an
    /// indication of trouble.
    BothExistLegacyPreserved { old: PathBuf, new: PathBuf },
    /// Migration succeeded: legacy copied to new path via atomic
    /// staging rename, legacy preserved as a backup.
    Migrated { old: PathBuf, new: PathBuf },
}

/// Resolve the OLD Tauri `app_data_dir` from platform conventions.
/// Used by the pre-runtime migration; the AppHandle isn't available
/// yet at that stage AND the AppHandle is keyed by the NEW identifier
/// anyway, so it couldn't surface the legacy path even if we had it.
///
/// Returns `None` when the environment variable required to anchor the
/// path is missing or empty — `HOME` on Unix, `APPDATA` on Windows. In
/// that case the migration silently no-ops; there is no sensible
/// fallback (the new path also depends on the same env vars and would
/// be equally unrooted).
pub fn legacy_tauri_app_data_dir() -> Option<PathBuf> {
    tauri_app_data_dir_for(OLD_BUNDLE_ID)
}

/// Resolve the CURRENT (post-rebrand) Tauri `app_data_dir` from
/// platform conventions. Used by the pre-runtime migration as the
/// destination for the copy — pre-runtime means we run before
/// `tauri::Builder::default()` so we can't call `app.path().app_data_dir()`.
///
/// CRITICAL: this resolver MUST agree with Tauri 2's own resolution at
/// runtime, otherwise the migration copies data into a path Tauri never
/// reads. Both sides use the same XDG / Library / APPDATA conventions
/// keyed by the bundle identifier, so they agree by construction as
/// long as `NEW_BUNDLE_ID` matches `tauri.conf.json#identifier`.
pub fn current_tauri_app_data_dir() -> Option<PathBuf> {
    tauri_app_data_dir_for(NEW_BUNDLE_ID)
}

/// Platform-aware resolver shared by [`legacy_tauri_app_data_dir`] and
/// [`current_tauri_app_data_dir`]. Public so integration tests in
/// sibling crates can substitute an arbitrary identifier — production
/// callers should prefer the two wrappers above.
pub fn tauri_app_data_dir_for(identifier: &str) -> Option<PathBuf> {
    // Exactly one of the four cfg blocks below is present per target
    // compile. Each is a tail expression that becomes the function's
    // return value. Avoiding explicit `return` keeps clippy's
    // needless_return lint happy on every platform.
    #[cfg(target_os = "linux")]
    {
        // XDG_DATA_HOME wins when set and non-empty, per the XDG Base
        // Directory specification. Tauri 2 honours the same precedence
        // when resolving `app_data_dir`, so the migration must too —
        // otherwise on a custom-XDG setup we'd copy from the wrong path.
        if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
            if !xdg.is_empty() {
                return Some(PathBuf::from(xdg).join(identifier));
            }
        }
        let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?;
        Some(
            PathBuf::from(home)
                .join(".local")
                .join("share")
                .join(identifier),
        )
    }

    #[cfg(target_os = "macos")]
    {
        let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?;
        Some(
            PathBuf::from(home)
                .join("Library")
                .join("Application Support")
                .join(identifier),
        )
    }

    #[cfg(target_os = "windows")]
    {
        let appdata = std::env::var("APPDATA").ok().filter(|s| !s.is_empty())?;
        Some(PathBuf::from(appdata).join(identifier))
    }

    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        let _ = identifier;
        None
    }
}

/// Drive the migration end-to-end against the Tauri-resolved NEW path
/// and an explicit OLD path (typically from
/// [`legacy_tauri_app_data_dir`], factored out so tests can inject
/// synthetic paths).
///
/// Failure mode: any I/O error from the copy is logged at error
/// severity, the partial staging dir is torn down, and the function
/// returns `NoLegacyFound`. The user is better off with a working but
/// fresh app than a refusing-to-start app — the hand-rolled
/// `paths.rs` migration already covers the user's transcripts and
/// models, so even a total-failure here only loses webview-keyed state
/// (preferences, session storage, plugin geometry).
pub fn migrate_tauri_app_data_dir_with_paths(old: &Path, new: &Path) -> AppDataMigrationStatus {
    let old_exists = old.exists();
    let new_exists = new.exists();

    match (old_exists, new_exists) {
        (false, _) => AppDataMigrationStatus::NoLegacyFound,
        (true, true) => {
            // Both exist. Refuse to merge — that risks clobbering user
            // state that's already been changed under the new
            // identifier. Caller logs a warning; legacy is preserved
            // as-is for manual recovery.
            AppDataMigrationStatus::BothExistLegacyPreserved {
                old: old.to_path_buf(),
                new: new.to_path_buf(),
            }
        }
        (true, false) => {
            // The interesting case. Copy via an atomic staging dir to
            // avoid leaving a half-written NEW path on copy failure.
            match copy_via_staging(old, new) {
                Ok(()) => AppDataMigrationStatus::Migrated {
                    old: old.to_path_buf(),
                    new: new.to_path_buf(),
                },
                Err(e) => {
                    tracing::error!(
                        target: "lumotia_startup",
                        err = ?e,
                        old = %old.display(),
                        new = %new.display(),
                        "failed to migrate Tauri app_data_dir; new install will start fresh"
                    );
                    AppDataMigrationStatus::NoLegacyFound
                }
            }
        }
    }
}

/// Copy `old` to `new` via a `<new>.migrating` staging directory, then
/// rename atomically into place. If the copy fails partway through, the
/// partial staging dir is torn down and `new` does not appear.
fn copy_via_staging(old: &Path, new: &Path) -> Result<(), std::io::Error> {
    let staging = staging_path_for(new);

    // Wipe any stray staging dir from a previous crashed migration.
    if staging.exists() {
        std::fs::remove_dir_all(&staging)?;
    }

    if let Some(parent) = new.parent() {
        std::fs::create_dir_all(parent)?;
    }

    if let Err(e) = copy_dir_recursive(old, &staging) {
        // Best-effort teardown; the original copy error is what the
        // caller wants to see.
        let _ = std::fs::remove_dir_all(&staging);
        return Err(e);
    }

    // Atomic rename. If `new` got created between the existence check
    // and here (concurrent process / race), the rename returns an
    // error on Windows and we surface it; on Unix it would clobber,
    // which we don't want either, so we re-check.
    if new.exists() {
        let _ = std::fs::remove_dir_all(&staging);
        return Err(std::io::Error::new(
            std::io::ErrorKind::AlreadyExists,
            "new app_data_dir appeared during migration; refusing to clobber",
        ));
    }

    std::fs::rename(&staging, new)
}

fn staging_path_for(new: &Path) -> PathBuf {
    // Same-parent staging keeps the rename on the same filesystem (no
    // EXDEV). We append a fixed suffix rather than a timestamp so a
    // crashed migration's residue is reliably cleaned up by the
    // pre-copy `remove_dir_all` on the next launch.
    let mut name = new
        .file_name()
        .map(|s| s.to_os_string())
        .unwrap_or_default();
    name.push(".migrating");
    match new.parent() {
        Some(parent) => parent.join(name),
        None => PathBuf::from(name),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn write_file(path: &Path, contents: &[u8]) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, contents).unwrap();
    }

    #[test]
    fn migrates_when_only_legacy_exists() {
        let tmp = tempfile::tempdir().unwrap();
        let old = tmp.path().join("uk.co.corbel.magnotia");
        let new = tmp.path().join("consulting.corbel.lumotia");

        // Synthesise a fake legacy app_data_dir: a leveldb log under the
        // localStorage tree (proxy for the webview's keyed storage) and
        // a window-state.json sat next to it (proxy for plugin state).
        let leveldb = old.join("localStorage").join("leveldb").join("000003.log");
        write_file(&leveldb, b"sentinel-leveldb-bytes");
        let window_state = old.join("window-state.json");
        write_file(&window_state, b"{\"main\":{\"x\":100,\"y\":200}}");

        let status = migrate_tauri_app_data_dir_with_paths(&old, &new);

        assert!(matches!(status, AppDataMigrationStatus::Migrated { .. }));

        let migrated_leveldb = new.join("localStorage").join("leveldb").join("000003.log");
        let migrated_window_state = new.join("window-state.json");
        assert_eq!(
            fs::read(&migrated_leveldb).unwrap(),
            b"sentinel-leveldb-bytes"
        );
        assert_eq!(
            fs::read(&migrated_window_state).unwrap(),
            b"{\"main\":{\"x\":100,\"y\":200}}"
        );

        // Legacy preserved as a backup. The user can delete it manually
        // once they've confirmed their state survived.
        assert!(old.exists(), "legacy path must be preserved as a backup");
        assert!(leveldb.exists());

        // Staging directory is cleaned up.
        assert!(!tmp
            .path()
            .join("consulting.corbel.lumotia.migrating")
            .exists());
    }

    #[test]
    fn no_op_when_legacy_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let old = tmp.path().join("uk.co.corbel.magnotia");
        let new = tmp.path().join("consulting.corbel.lumotia");

        let status = migrate_tauri_app_data_dir_with_paths(&old, &new);

        assert_eq!(status, AppDataMigrationStatus::NoLegacyFound);
        assert!(!new.exists());
    }

    #[test]
    fn warns_but_does_not_copy_when_both_paths_exist() {
        let tmp = tempfile::tempdir().unwrap();
        let old = tmp.path().join("uk.co.corbel.magnotia");
        let new = tmp.path().join("consulting.corbel.lumotia");

        let old_file = old.join("legacy.txt");
        let new_file = new.join("new.txt");
        write_file(&old_file, b"legacy");
        write_file(&new_file, b"new");

        let status = migrate_tauri_app_data_dir_with_paths(&old, &new);

        match status {
            AppDataMigrationStatus::BothExistLegacyPreserved { .. } => {}
            other => panic!("expected BothExistLegacyPreserved, got {other:?}"),
        }

        // Neither side mutated.
        assert_eq!(fs::read(&old_file).unwrap(), b"legacy");
        assert_eq!(fs::read(&new_file).unwrap(), b"new");
        // No legacy.txt landed in new — confirms we did not merge.
        assert!(!new.join("legacy.txt").exists());
    }

    #[test]
    fn idempotent_when_legacy_already_migrated() {
        // First run: legacy → new copy. Second run: new exists, legacy
        // still exists (preserved as backup), and the function must
        // not re-copy or error.
        let tmp = tempfile::tempdir().unwrap();
        let old = tmp.path().join("uk.co.corbel.magnotia");
        let new = tmp.path().join("consulting.corbel.lumotia");
        write_file(&old.join("file.txt"), b"v1");

        let first = migrate_tauri_app_data_dir_with_paths(&old, &new);
        assert!(matches!(first, AppDataMigrationStatus::Migrated { .. }));

        // Simulate the user changing state on the new side after first
        // launch. If the second run merged or re-copied, this would be
        // clobbered back to "v1".
        fs::write(new.join("file.txt"), b"v2-edited-by-user").unwrap();

        let second = migrate_tauri_app_data_dir_with_paths(&old, &new);
        assert!(matches!(
            second,
            AppDataMigrationStatus::BothExistLegacyPreserved { .. }
        ));

        assert_eq!(
            fs::read(new.join("file.txt")).unwrap(),
            b"v2-edited-by-user"
        );
    }
}
