//! `#[tauri::command]` wrappers. Task 2 moved the engine modules into
//! `cambium-core` (tauri-free); everything here is a thin delegation to that
//! crate, except the handful of commands that genuinely need a Tauri type
//! (`AppHandle`, `State`, `ipc::Channel`) and so cannot move.

use cambium_core::config::{CambiumDirs, DeviceIdentity, PinStore, WorkspacesFile};
use cambium_core::{hashcache, pinned_http, secrets, state_store, transfer, workspace};
use std::collections::{BTreeMap, HashMap};

// ---------------------------------------------------------------- pinned_http

#[tauri::command]
pub async fn pinned_fetch(
    method: String,
    url: String,
    headers: HashMap<String, String>,
    body: Option<Vec<u8>>,
    pin: Option<String>,
    binary: Option<bool>,
) -> Result<pinned_http::FetchResponse, String> {
    pinned_http::pinned_fetch(method, url, headers, body, pin, binary).await
}

#[tauri::command]
pub async fn fetch_server_certificate(host: String) -> Result<pinned_http::CertInfo, String> {
    pinned_http::fetch_server_certificate(host).await
}

// -------------------------------------------------------------------- secrets

#[tauri::command]
pub fn save_secret(account: String, value: String) -> Result<(), String> {
    secrets::save_secret(account, value)
}

#[tauri::command]
pub fn load_secret(account: String) -> Result<Option<String>, String> {
    secrets::load_secret(account)
}

#[tauri::command]
pub fn delete_secret(account: String) -> Result<(), String> {
    secrets::delete_secret(account)
}

// ------------------------------------------------------------------ workspace

#[tauri::command]
pub fn get_device_name() -> String {
    workspace::get_device_name()
}

#[tauri::command]
pub async fn set_workspace_writable(
    payload: workspace::SetWritablePayload,
) -> Result<workspace::FailedResult, ()> {
    // Return `{ ok:false }` for a bad set-writable request; mirror
    // delete's `{ ok:false, failed: paths }` shape here too for consistency —
    // the renderer only checks ok/failed and this makes the failure explicit.
    if payload.folder_path.trim().is_empty() {
        return Ok(workspace::FailedResult { ok: false, failed: payload.paths.clone() });
    }
    Ok(workspace::set_writable_impl(&payload.folder_path, &payload.paths, payload.writable).await)
}

#[tauri::command]
pub async fn delete_workspace_files(
    payload: workspace::DeletePayload,
) -> Result<workspace::FailedResult, ()> {
    // Return `{ ok:false, failed: paths }` for a bad delete request.
    if payload.folder_path.trim().is_empty() {
        return Ok(workspace::FailedResult { ok: false, failed: payload.paths.clone() });
    }
    Ok(workspace::delete_files_impl(&payload.folder_path, &payload.paths, &payload.folders).await)
}

#[tauri::command]
pub async fn import_workspace_paths(
    payload: workspace::ImportPayload,
) -> Result<workspace::ImportOutcome, ()> {
    Ok(tauri::async_runtime::spawn_blocking(move || workspace::import_impl(payload))
        .await
        .unwrap_or_else(|e| {
            let mut out = workspace::ImportOutcome::default();
            out.errors
                .push(workspace::ImportError { path: "(import)".into(), error: e.to_string() });
            out
        }))
}

#[tauri::command]
pub async fn create_workspace_folder(
    payload: workspace::CreateFolderPayload,
) -> Result<workspace::CreateFolderResult, ()> {
    Ok(workspace::create_folder_impl(&payload.folder_path, &payload.rel_path).await)
}

#[tauri::command]
pub async fn read_workspace_tree(
    app: tauri::AppHandle,
    state: tauri::State<'_, workspace::HashCache>,
    folder_path: String,
    options: Option<workspace::TreeOptions>,
) -> Result<workspace::TreeResult, ()> {
    use tauri::Manager;

    if folder_path.trim().is_empty() {
        return Ok(workspace::TreeResult::Err {
            ok: false,
            error: "No folder path provided.".into(),
        });
    }
    let meta = match tokio::fs::metadata(&folder_path).await {
        Ok(m) => m,
        Err(_) => {
            return Ok(workspace::TreeResult::Err {
                ok: false,
                error: format!("Folder not found: {folder_path}"),
            })
        }
    };
    if !meta.is_dir() {
        return Ok(workspace::TreeResult::Err {
            ok: false,
            error: format!("Not a folder: {folder_path}"),
        });
    }
    let with_hashes = options.map(|o| o.hashes).unwrap_or(false);
    let root = std::path::PathBuf::from(&folder_path);
    let cache_path =
        app.path().app_cache_dir().ok().map(|dir| hashcache::cache_file_path(&dir));

    // Hydrate the shared cache from disk once per app run, then scan against
    // a snapshot so a long cold scan never holds the lock (transfer hashes
    // pulled files through the same cache concurrently).
    let mut entries = {
        let mut guard = state.lock().await;
        if !guard.loaded {
            if let Some(path) = &cache_path {
                let mut loaded = hashcache::load_entries(path);
                // A pull may have inserted fresh hashes before the first scan
                // hydrated the cache — live inserts win over the disk snapshot.
                loaded.extend(guard.entries.drain());
                guard.entries = loaded;
            }
            guard.loaded = true;
        }
        guard.entries.clone()
    };

    let (children, live_keys, new_count) =
        workspace::scan_workspace(&root, with_hashes, &mut entries).await;

    if with_hashes {
        let (merged, pruned) = {
            let mut guard = state.lock().await;
            // Merge scan results into the shared map (files hashed by
            // transfer meanwhile are kept — insert doesn't clobber them
            // with anything stale because keys embed size+mtime).
            for (key, hash) in entries {
                guard.entries.entry(key).or_insert(hash);
            }
            let pruned = hashcache::prune_stale(&mut guard.entries, &root, &live_keys);
            (guard.entries.clone(), pruned)
        };
        if new_count > 0 || pruned > 0 {
            if let Some(path) = cache_path {
                tokio::task::spawn_blocking(move || {
                    let _ = hashcache::save_entries(&path, &merged);
                });
            }
        }
    }

    Ok(workspace::TreeResult::Ok { ok: true, children, modified_ms: workspace::mtime_ms(&meta) })
}

// -------------------------------------------------------------------- transfer

// `Channel` implements CommandArg but not Deserialize, so it can't ride
// inside `payload` or an Option — it is a required top-level arg the JS
// side always passes (platform.js constructs one even without a callback).
fn channel_sink(channel: tauri::ipc::Channel<transfer::TransferProgress>) -> transfer::ProgressSink {
    Some(std::sync::Arc::new(move |p: transfer::TransferProgress| {
        let _ = channel.send(p);
    }) as std::sync::Arc<dyn Fn(transfer::TransferProgress) + Send + Sync>)
}

#[tauri::command]
pub async fn pull_workspace_files(
    state: tauri::State<'_, workspace::HashCache>,
    payload: transfer::PullPayload,
    on_progress: tauri::ipc::Channel<transfer::TransferProgress>,
    pin: Option<String>,
) -> Result<transfer::PullResult, ()> {
    if let Some(guarded) = transfer::blank_pull_guard(&payload.folder_path) {
        return Ok(guarded);
    }
    let _ = tokio::fs::create_dir_all(&payload.folder_path).await;
    Ok(transfer::pull_files_impl(
        &payload.folder_path,
        &payload.files,
        &payload.headers,
        payload.writable,
        payload.archive_url.as_deref(),
        &state,
        channel_sink(on_progress),
        pin.as_deref(),
    )
    .await)
}

#[tauri::command]
pub async fn upload_workspace_files(
    payload: transfer::UploadPayload,
    on_progress: tauri::ipc::Channel<transfer::TransferProgress>,
    pin: Option<String>,
) -> Result<transfer::UploadResult, ()> {
    // Reject a blank folderPath before attempting an upload.
    if payload.folder_path.trim().is_empty() {
        return Ok(transfer::UploadResult { ok: false, results: vec![] });
    }
    Ok(transfer::upload_files_impl(
        &payload.folder_path,
        &payload.files,
        &payload.headers,
        channel_sink(on_progress),
        pin.as_deref(),
    )
    .await)
}

// ---------------------------------------------------------------- state_store

fn resolve_state_path(key: &str) -> Result<std::path::PathBuf, String> {
    let dirs = CambiumDirs::resolve()?;
    Ok(state_store::state_file_path(dirs.state_base(), key))
}

#[tauri::command]
pub async fn load_client_state(key: String) -> Result<Option<String>, String> {
    let path = resolve_state_path(&key)?;
    match tokio::fs::read_to_string(&path).await {
        Ok(s) => Ok(Some(s)),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e.to_string()),
    }
}

#[tauri::command]
pub async fn save_client_state(key: String, value: String) -> Result<(), String> {
    let path = resolve_state_path(&key)?;
    tokio::task::spawn_blocking(move || state_store::write_atomic(&path, &value))
        .await
        .map_err(|e| e.to_string())?
        .map_err(|e| e.to_string())
}

// -------------------------------------------------------------------- config
//
// Device identity, host-key pins, and the workspaces list all live in the
// shared Cambium config dir (`cambium_core::config`) so the desktop app and
// the `cambium` CLI agree on one device/pin-store/workspace-list, not one
// each. Each command re-resolves `CambiumDirs` per call — cheap (no I/O
// beyond `create_dir_all`, already idempotent) and avoids threading dir state
// through Tauri's managed-state machinery for what is a handful of small,
// infrequent JSON reads/writes.

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceIdentityDto {
    pub device_id: String,
    pub device_name: String,
}

#[tauri::command]
pub async fn get_device_identity(
    fallback_id: Option<String>,
    fallback_name: Option<String>,
) -> Result<DeviceIdentityDto, String> {
    let dirs = CambiumDirs::resolve()?;
    let identity = DeviceIdentity::load_or_create(&dirs, fallback_id, fallback_name)?;
    Ok(DeviceIdentityDto {
        device_id: identity.device_id,
        device_name: identity.device_name,
    })
}

#[tauri::command]
pub async fn pins_all() -> Result<BTreeMap<String, String>, String> {
    let dirs = CambiumDirs::resolve()?;
    PinStore::new(&dirs).all()
}

#[tauri::command]
pub async fn pins_set(host: String, fingerprint: String) -> Result<(), String> {
    let dirs = CambiumDirs::resolve()?;
    PinStore::new(&dirs).set(&host, &fingerprint)
}

#[tauri::command]
pub async fn pins_remove(host: String) -> Result<(), String> {
    let dirs = CambiumDirs::resolve()?;
    PinStore::new(&dirs).remove(&host)
}

#[tauri::command]
pub async fn workspaces_load() -> Result<String, String> {
    let dirs = CambiumDirs::resolve()?;
    let file = WorkspacesFile::load(&dirs)?;
    serde_json::to_string(&file).map_err(|e| e.to_string())
}

/// Two-step validation mirroring core's `read_versioned`
/// (crates/cambium-core/src/config.rs): check `formatVersion` on the RAW
/// JSON value first, so a newer, non-additive schema (renamed/removed
/// required fields) yields the contractual "newer Cambium" error instead of
/// a generic serde "missing field" one; only then do the typed parse.
pub(crate) fn validate_workspaces_json(value: &str) -> Result<WorkspacesFile, String> {
    let raw: serde_json::Value = serde_json::from_str(value).map_err(|e| e.to_string())?;
    if let Some(v) = raw.get("formatVersion").and_then(|v| v.as_u64()) {
        if v > 1 {
            return Err(format!(
                "workspaces.json was written by a newer Cambium (format {v}); update this program."
            ));
        }
    }
    serde_json::from_value(raw).map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn workspaces_save(value: String) -> Result<(), String> {
    let dirs = CambiumDirs::resolve()?;
    let file = validate_workspaces_json(&value)?;
    file.save(&dirs)
}

// -------------------------------------------------------------- console panel
//
// The desktop console runs the exact same grammar + executor the `cambium`
// CLI does (cambium_core::{commands, executor}) — no `/api/command` round
// trip. The GUI's current session/project/workspace ride in as `ctx`; the
// executor runs non-interactively (it can't prompt in a webview), so anything
// the CLI would ask for interactively must be answered by ctx or a `-f` flag,
// exactly like a piped/CI CLI run.

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsoleCtx {
    server: String,
    token: String,
    username: String,
    device_id: String,
    device_name: String,
    project_id: Option<String>,
    project_name: Option<String>,
    workspace_path: Option<String>,
}

#[derive(serde::Serialize)]
pub struct ConsoleResult {
    ok: bool,
    output: String,
    /// Refresh hints ("files"/"locks"/"users"/"projects") the panel maps onto
    /// the matching ServerData/Connection refreshers.
    changed: Vec<String>,
}

/// Builds the `ExecContext` a console-grammar command runs against: mirrors
/// the GUI's live session into `session.json` (the executor's changelist
/// keying and pin lookups read `SessionCache`, and this also lets a
/// `cambium` CLI in a terminal share the same session), then loads a
/// pre-authenticated `ApiClient` and the pre-seeded project/workspace
/// straight from `ctx` — no interactive auth resolution (see the module
/// note), since the desktop app authenticates through JS. Every console
/// entry point (`run_console_command`, `run_command_structured`) shares this
/// so the two never drift on how `ctx` maps to `ExecContext`.
fn build_exec_context(
    ctx: ConsoleCtx,
    progress: transfer::ProgressSink,
) -> Result<cambium_core::executor::ExecContext, String> {
    use cambium_core::api::ApiClient;
    use cambium_core::config::SessionCache;
    use cambium_core::executor::{self, ExecContext};

    let dirs = CambiumDirs::resolve()?;

    let session = SessionCache {
        format_version: 1,
        host: ctx.server.clone(),
        username: ctx.username.clone(),
        token: ctx.token.clone(),
    };
    session.save(&dirs)?;

    // Plain-http dev servers carry no pin; https servers use the one
    // trust-on-first-use stored.
    let pin = if ctx.server.starts_with("http://") {
        None
    } else {
        PinStore::new(&dirs).get(&executor::bare_host(&ctx.server)).ok().flatten()
    };
    let mut api = ApiClient::new(&ctx.server, pin).map_err(|e| e.to_string())?;
    api.token = Some(ctx.token.clone());
    api.project_id = ctx.project_id.clone();

    let me = DeviceIdentity { device_id: ctx.device_id.clone(), device_name: ctx.device_name.clone() };
    let project = match (&ctx.project_id, &ctx.project_name) {
        (Some(id), Some(name)) => Some((id.clone(), name.clone())),
        (Some(id), None) => Some((id.clone(), id.clone())),
        _ => None,
    };
    let workspace = ctx.workspace_path.clone().map(std::path::PathBuf::from);

    Ok(ExecContext { dirs, api: Some(api), me: Some(me), project, workspace, interactive: false, progress })
}

#[tauri::command]
pub async fn run_console_command(line: String, ctx: ConsoleCtx) -> Result<ConsoleResult, String> {
    use cambium_core::commands::parse_line;
    use cambium_core::executor;

    // Parse errors (and clap's `help`/usage output) are shown as a failed
    // result rather than an Err — the console never crashes on a typo.
    let cli = match parse_line(&line) {
        Ok(c) => c,
        Err(text) => return Ok(ConsoleResult { ok: false, output: text, changed: Vec::new() }),
    };

    let mut exec = build_exec_context(ctx, None)?;
    let out = executor::run(cli, &mut exec).await;
    Ok(ConsoleResult {
        ok: out.ok,
        output: out.human,
        changed: out.changed.iter().map(|s| s.to_string()).collect(),
    })
}

/// `CommandOutput`'s wire shape for the GUI's structured verb calls (Tasks
/// 3-4): camelCase, `changed` as owned `String`s (the core type's is
/// `&'static str`, which doesn't serialize through serde as-is).
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandOutputDto {
    pub ok: bool,
    pub code: i32,
    pub human: String,
    pub data: serde_json::Value,
    pub changed: Vec<String>,
}

impl From<cambium_core::commands::CommandOutput> for CommandOutputDto {
    fn from(out: cambium_core::commands::CommandOutput) -> Self {
        Self {
            ok: out.ok,
            code: out.code,
            human: out.human,
            data: out.data,
            changed: out.changed.into_iter().map(str::to_string).collect(),
        }
    }
}

/// The GUI's generalized console command path (Task 2/18b): same shared
/// grammar + executor as `run_console_command`, but returning the full
/// structured `CommandOutput` (not just `{ok, output, changed}`) and
/// threading a real progress channel through to `ctx.progress`, so a
/// GUI-issued `pull`/`submit` can drive the toolbar progress strip the same
/// way the native transfer commands do. Parse errors come back as `Ok` with
/// `code: 2`, never `Err` — same contract as `run_console_command`.
#[tauri::command]
pub async fn run_command_structured(
    line: String,
    ctx: ConsoleCtx,
    on_progress: tauri::ipc::Channel<transfer::TransferProgress>,
) -> Result<CommandOutputDto, String> {
    use cambium_core::commands::parse_line;
    use cambium_core::executor;

    let cli = match parse_line(&line) {
        Ok(c) => c,
        Err(text) => {
            return Ok(CommandOutputDto {
                ok: false,
                code: 2,
                human: text,
                data: serde_json::Value::Null,
                changed: Vec::new(),
            });
        }
    };

    let mut exec = build_exec_context(ctx, channel_sink(on_progress))?;
    let out = executor::run(cli, &mut exec).await;
    Ok(out.into())
}

// ---------------------------------------------------------------- file status

/// FileTree's status column, computed by the same engine `cambium status`
/// uses: the GUI hands over what it already fetched/hydrated (depot tree,
/// workspace listing, locks, changelist, manifest, roster), and this returns
/// the `cambium status --json` vocabulary plus the flattened depot tree.
///
/// Async + spawn_blocking (same pattern as `import_workspace_paths`): the
/// engine is CPU-bound and O(files), and a sync command runs inline on the
/// platform main thread — on macOS that froze every native event for the
/// full deserialize/compute/serialize (~175 ms on a 17,942-file project,
/// several times per poll tick).
#[tauri::command]
pub async fn compute_status(
    inputs: cambium_core::gui::StatusInputsDto,
) -> Result<cambium_core::gui::ComputeStatusResponse, String> {
    tauri::async_runtime::spawn_blocking(move || cambium_core::gui::compute_status_dto(inputs))
        .await
        .map_err(|e| e.to_string())
}

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

    fn test_console_ctx() -> ConsoleCtx {
        ConsoleCtx {
            server: "http://127.0.0.1:1".into(),
            token: "tok".into(),
            username: "@robin".into(),
            device_id: "dev-1".into(),
            device_name: "Test Box".into(),
            project_id: None,
            project_name: None,
            workspace_path: None,
        }
    }

    fn no_op_channel() -> tauri::ipc::Channel<transfer::TransferProgress> {
        tauri::ipc::Channel::new(|_| Ok(()))
    }

    #[tokio::test]
    async fn compute_status_matches_engine_output() {
        // Behavior lock for the async refactor: the command must be a pure
        // pass-through to gui::compute_status_dto, byte-identical output.
        let inputs_json = serde_json::json!({
            "depotTree": {"children": [
                {"type": "file", "name": "a.txt", "hash": "h1", "sizeBytes": 10, "headRevision": 3}
            ]},
            "localChildren": [
                {"name": "a.txt", "type": "file", "sizeBytes": 10, "hash": "h2", "writable": true}
            ],
            "locks": {},
            "changelist": {"add": [], "del": [], "delFolders": []},
            "baseHashes": {"a.txt": "h1"},
            "me": {"username": "@alice", "deviceId": "dev1"},
            "ignoreText": "",
            "users": null
        });
        let parse = || {
            serde_json::from_value::<cambium_core::gui::StatusInputsDto>(inputs_json.clone())
                .unwrap()
        };
        let expected =
            serde_json::to_value(cambium_core::gui::compute_status_dto(parse())).unwrap();
        let got = serde_json::to_value(compute_status(parse()).await.unwrap()).unwrap();
        assert_eq!(got, expected);
    }

    #[tokio::test]
    async fn run_command_structured_parse_error_is_ok_not_err() {
        // A typo/unknown verb must never surface as a rejected promise on the
        // JS side (same contract as run_console_command) — it comes back as
        // a failed DTO carrying clap's own rendered message, code 2.
        let expected_human = cambium_core::commands::parse_line("frobnicate").unwrap_err();

        let dto = run_command_structured(
            "frobnicate".to_string(),
            test_console_ctx(),
            no_op_channel(),
        )
        .await
        .expect("parse errors must return Ok, not Err");

        assert!(!dto.ok);
        assert_eq!(dto.code, 2);
        assert_eq!(dto.human, expected_human);
        assert_eq!(dto.data, serde_json::Value::Null);
        assert!(dto.changed.is_empty());
    }

    #[test]
    fn command_output_dto_serializes_camel_case() {
        let dto = CommandOutputDto {
            ok: true,
            code: 0,
            human: "Checked out //a.txt.".to_string(),
            data: serde_json::Value::Null,
            changed: vec!["files".to_string(), "locks".to_string()],
        };
        let value = serde_json::to_value(&dto).unwrap();
        assert_eq!(
            value,
            serde_json::json!({
                "ok": true,
                "code": 0,
                "human": "Checked out //a.txt.",
                "data": null,
                "changed": ["files", "locks"],
            })
        );
    }

    #[tokio::test]
    async fn set_writable_rejects_blank_folder_path() {
        let payload = workspace::SetWritablePayload {
            folder_path: "   ".into(),
            paths: vec!["a.txt".into(), "b.txt".into()],
            writable: true,
        };
        let res = set_workspace_writable(payload).await.unwrap();
        assert!(!res.ok);
        assert_eq!(res.failed, vec!["a.txt".to_string(), "b.txt".to_string()]);
    }

    #[tokio::test]
    async fn delete_rejects_blank_folder_path() {
        let payload = workspace::DeletePayload {
            folder_path: "".into(),
            paths: vec!["a.txt".into()],
            folders: vec!["sub".into()],
        };
        let res = delete_workspace_files(payload).await.unwrap();
        assert!(!res.ok);
        assert_eq!(res.failed, vec!["a.txt".to_string()]);
    }

    #[tokio::test]
    async fn upload_rejects_blank_folder_path() {
        let payload = transfer::UploadPayload {
            folder_path: "".into(),
            files: vec![transfer::UploadFile {
                path: "a.txt".into(),
                url: "http://x/a".into(),
                size_bytes: None,
            }],
            headers: HashMap::new(),
        };
        let res = upload_workspace_files(payload, tauri::ipc::Channel::new(|_| Ok(())), None)
            .await
            .unwrap();
        assert!(!res.ok);
        assert!(res.results.is_empty());
    }

    #[test]
    fn workspaces_save_newer_format_wins_over_missing_field() {
        // Version-skew contract: a formatVersion:2 payload with a RENAMED
        // required field (non-additive schema change — `workspaces` →
        // `projects`) must yield the "newer Cambium" error, NOT a generic
        // serde "missing field" error from the typed parse.
        let payload = r#"{"formatVersion":2,"activeProject":null,"projects":{}}"#;
        let err = validate_workspaces_json(payload).unwrap_err();
        assert!(err.contains("newer Cambium"), "{err}");
        assert!(!err.contains("missing field"), "{err}");
    }

    #[test]
    fn workspaces_save_current_format_roundtrips() {
        let payload = r#"{"formatVersion":1,"activeProject":null,"workspaces":{}}"#;
        let file = validate_workspaces_json(payload).unwrap();
        assert_eq!(file.format_version, 1);
        assert!(file.workspaces.is_empty());
    }
}
