// Phase 9. Thin filesystem write command for the save-dialog path.
//
// Path safety (Trust-1, code-atomiser-fix 2026-05-12): an OS save dialog
// is the intended entry point, but the Tauri IPC surface does not enforce
// that — a compromised webview can invoke `write_text_file_cmd` directly
// with any path the running process can write to. We therefore:
//
// 1. require the call to originate from the main window;
// 2. canonicalise the supplied path (resolves symlinks, rejects bad
//    parents);
// 3. assert the canonical path is inside a known-good base directory
//    (app data, downloads, documents, or desktop). Anything outside is
//    rejected with a clear error.
//
// The base-dir allowlist mirrors where the OS save dialog is expected to
// land; it is deliberately broader than `recordings_dir()` because users
// routinely save transcripts to Documents or Desktop. Adding new bases is
// a one-line change in `allowed_export_bases`.

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

use tauri::Manager;

use crate::commands::security::ensure_main_window;

/// Write UTF-8 text to a user-chosen path. The path must (a) canonicalise
/// to a real, accessible path, and (b) live inside one of the allowlisted
/// base directories (app data dir, downloads, documents, desktop). Errors
/// propagate the OS message verbatim wrapped with the path so the toast
/// on the frontend is actionable.
#[tauri::command]
pub async fn write_text_file_cmd(
    app: tauri::AppHandle,
    window: tauri::WebviewWindow,
    path: String,
    contents: String,
) -> Result<(), String> {
    ensure_main_window(&window)?;

    let bases = allowed_export_bases(&app);
    let resolved = resolve_export_path(Path::new(&path), &bases)?;

    tokio::fs::write(&resolved, contents)
        .await
        .map_err(|e| format!("Failed to write {}: {e}", resolved.display()))
}

/// Collect the set of base directories under which `write_text_file_cmd`
/// is allowed to land. Returns the canonicalised form of each so the
/// later containment check can be a pure prefix comparison.
fn allowed_export_bases(app: &tauri::AppHandle) -> Vec<PathBuf> {
    let path = app.path();
    let mut bases: Vec<PathBuf> = Vec::new();
    for candidate in [
        path.app_data_dir().ok(),
        path.app_local_data_dir().ok(),
        path.download_dir().ok(),
        path.document_dir().ok(),
        path.desktop_dir().ok(),
    ]
    .into_iter()
    .flatten()
    {
        // Allow the base directory even if it does not yet exist on disk —
        // canonicalise the parent walk-up when possible.
        if let Ok(canon) = std::fs::canonicalize(&candidate) {
            bases.push(canon);
        } else {
            bases.push(candidate);
        }
    }
    bases
}

/// Resolve the requested write target. The canonicalised path must sit
/// inside one of `bases`.
///
/// Two-mode canonicalisation:
///
/// - **If the target already exists**, canonicalise the WHOLE path. This
///   follows any symlink at the target itself, so a symlink that sits
///   inside an allowlisted base but points outside it gets rejected
///   here. `tokio::fs::write` follows symlinks on open(2), so without
///   this branch a symlink at `~/Downloads/notes.md -> ~/.bashrc` would
///   pass the containment check (the path string is inside Downloads)
///   and the subsequent write would silently land in `~/.bashrc`.
///   Trust-1 audit residual closed in Phase B.5 (2026-05-14); parallels
///   `validate_output_folder`'s full-path-canonicalize in
///   `commands/audio.rs` (Trust-2).
///
/// - **If the target does not yet exist** (the typical save-dialog
///   case), canonicalise the parent and re-join the filename. The
///   parent must exist for `tokio::fs::write` to succeed anyway, so
///   checking it is no extra cost.
pub(crate) fn resolve_export_path(path: &Path, bases: &[PathBuf]) -> Result<PathBuf, String> {
    let canon_path = match std::fs::canonicalize(path) {
        Ok(canon) => canon,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            let parent = path.parent().ok_or_else(|| {
                format!(
                    "Refusing to write {}: path has no parent directory.",
                    path.display()
                )
            })?;
            let file_name = path.file_name().ok_or_else(|| {
                format!(
                    "Refusing to write {}: path has no filename.",
                    path.display()
                )
            })?;

            let canon_parent = std::fs::canonicalize(parent).map_err(|e| {
                format!(
                    "Refusing to write {}: cannot resolve parent dir ({e}).",
                    path.display()
                )
            })?;
            canon_parent.join(file_name)
        }
        Err(e) => {
            return Err(format!(
                "Refusing to write {}: cannot resolve path ({e}).",
                path.display()
            ));
        }
    };

    if !is_inside_any_base(&canon_path, bases) {
        return Err(format!(
            "Refusing to write {}: path is outside the allowed export directories \
             (app data, Downloads, Documents, Desktop). \
             Pick a destination inside one of those folders.",
            canon_path.display()
        ));
    }

    Ok(canon_path)
}

/// Pure containment check: does `candidate` sit at or under any path in
/// `bases`? Pure on purpose so the unit tests below can exercise the
/// rule without a Tauri app handle.
pub(crate) fn is_inside_any_base(candidate: &Path, bases: &[PathBuf]) -> bool {
    bases.iter().any(|base| candidate.starts_with(base))
}

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

    fn tempdir_canon(name: &str) -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().expect("tempdir");
        let canon = std::fs::canonicalize(dir.path()).expect("canonicalise tempdir");
        let _ = name;
        (dir, canon)
    }

    #[test]
    fn rejects_path_outside_allowlist() {
        let (_keep, base) = tempdir_canon("base");
        let bases = vec![base];
        // /etc/passwd is outside the temp base.
        let result = resolve_export_path(Path::new("/etc/passwd"), &bases);
        assert!(
            result.is_err(),
            "outside-base path must be rejected, got {result:?}"
        );
    }

    #[test]
    fn rejects_path_traversal_attempt() {
        // A "../../etc/passwd" relative to a sub-dir of the base must
        // resolve to /etc/passwd (or similar) which is OUTSIDE the
        // base — and we must reject it. We use the canonicalised
        // tempdir as the base, then construct a sibling escape path.
        let (_keep, base) = tempdir_canon("base");
        let sub = base.join("sub");
        std::fs::create_dir(&sub).expect("mkdir sub");

        let bases = vec![base.clone()];
        let traversal = sub.join("../../etc/passwd");
        let result = resolve_export_path(&traversal, &bases);
        assert!(
            result.is_err(),
            "../../etc/passwd traversal must be rejected, got {result:?}"
        );
    }

    #[test]
    fn accepts_path_inside_allowlist() {
        let (_keep, base) = tempdir_canon("base");
        let bases = vec![base.clone()];
        let target = base.join("out.md");
        let resolved = resolve_export_path(&target, &bases).expect("inside-base must be allowed");
        assert!(
            resolved.starts_with(&base),
            "resolved path {resolved:?} must sit under base {base:?}"
        );
    }

    #[test]
    fn accepts_nested_path_inside_allowlist() {
        let (_keep, base) = tempdir_canon("base");
        let nested = base.join("nested");
        std::fs::create_dir(&nested).expect("mkdir nested");
        let bases = vec![base.clone()];
        let target = nested.join("out.md");
        let resolved =
            resolve_export_path(&target, &bases).expect("nested inside-base must be allowed");
        assert!(resolved.starts_with(&base));
    }

    #[test]
    fn rejects_when_parent_does_not_exist() {
        let bases = vec![PathBuf::from("/")];
        let result =
            resolve_export_path(Path::new("/definitely-not-real-lumotia-fix/out.md"), &bases);
        assert!(
            result.is_err(),
            "nonexistent parent must be rejected, got {result:?}"
        );
    }

    #[test]
    fn is_inside_any_base_is_a_prefix_check() {
        let a = PathBuf::from("/tmp/a");
        let b = PathBuf::from("/tmp/b");
        let bases = vec![a.clone(), b];
        assert!(is_inside_any_base(&a.join("x.md"), &bases));
        assert!(is_inside_any_base(&a.join("nested/x.md"), &bases));
        assert!(!is_inside_any_base(Path::new("/tmp/other/x.md"), &bases));
    }

    /// Phase B.5 audit regression (2026-05-14). The Trust-1 fix
    /// canonicalised only the parent of the target path because the file
    /// typically does not exist yet. But if a symlink ALREADY exists at
    /// the target path pointing outside the allowlisted base,
    /// `tokio::fs::write` follows it on `File::create -> open(2)` and
    /// silently writes through to the outside target. The path-string
    /// containment check passed because the SYMLINK lives inside the
    /// base, even though the symlink's target does not.
    ///
    /// The fix canonicalises the full path when it exists (so the
    /// symlink resolves before the containment check) and only falls
    /// back to parent-canonicalize on NotFound. This regression test
    /// pins that contract.
    #[cfg(unix)]
    #[test]
    fn rejects_symlink_target_outside_allowlist() {
        let (_keep_base, base) = tempdir_canon("base");
        let (_keep_out, outside) = tempdir_canon("outside");
        // Real, writable file outside the base — the symlink target.
        let outside_target = outside.join("victim.txt");
        std::fs::write(&outside_target, b"original").expect("seed outside target");

        // Inside-base symlink pointing to the outside file.
        let symlink_path = base.join("notes.md");
        std::os::unix::fs::symlink(&outside_target, &symlink_path)
            .expect("create symlink inside base pointing outside");

        let bases = vec![base.clone()];
        let result = resolve_export_path(&symlink_path, &bases);
        assert!(
            result.is_err(),
            "symlink target outside allowlist must be rejected, got {result:?}"
        );
        let err = result.err().unwrap();
        assert!(
            err.contains("outside the allowed export directories"),
            "unexpected error shape: {err}"
        );
    }

    /// Symmetric to the rejection above: a symlink whose target stays
    /// inside the same base must still be accepted, so legitimate users
    /// of in-base symlinks (e.g. a tilde-expansion alias inside the base)
    /// are not regressed.
    #[cfg(unix)]
    #[test]
    fn accepts_symlink_target_inside_allowlist() {
        let (_keep, base) = tempdir_canon("base");
        let real_path = base.join("real.md");
        std::fs::write(&real_path, b"original").expect("seed real target");

        let symlink_path = base.join("alias.md");
        std::os::unix::fs::symlink(&real_path, &symlink_path)
            .expect("create in-base alias symlink");

        let bases = vec![base.clone()];
        let resolved = resolve_export_path(&symlink_path, &bases)
            .expect("in-base symlink must resolve and pass");
        assert!(resolved.starts_with(&base));
    }
}
