//! Pure pass-through status computation for the desktop GUI: the app hands
//! over the server data it already fetched (ServerDataContext) plus its
//! hydrated changelist/manifest; core flattens, runs compute_statuses, and
//! answers in the exact `cambium status --json` vocabulary.

use std::collections::BTreeMap;

use crate::config::Changelist;
use crate::executor::render_status;
use crate::ignore::IgnoreMatcher;
use crate::status::{compute_statuses, flatten_depot_tree, LocalFile, StatusInputs};

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalNodeDto {
    pub name: String,
    #[serde(rename = "type")]
    pub node_type: String,
    #[serde(default)]
    pub size_bytes: Option<u64>,
    #[serde(default)]
    pub children: Option<Vec<LocalNodeDto>>,
    #[serde(default)]
    pub hash: Option<String>,
    #[serde(default)]
    pub writable: Option<bool>,
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangelistDto {
    pub add: Vec<String>,
    pub del: Vec<String>,
    pub del_folders: Vec<String>,
}

impl From<ChangelistDto> for Changelist {
    fn from(dto: ChangelistDto) -> Self {
        Changelist {
            add: dto.add.into_iter().collect(),
            del: dto.del.into_iter().collect(),
            del_folders: dto.del_folders.into_iter().collect(),
        }
    }
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeDto {
    pub username: String,
    pub device_id: String,
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusInputsDto {
    /// The GUI's cached depot tree object ({children,...}), exactly as
    /// /api/depot/tree returned it — flatten_depot_tree's input shape.
    pub depot_tree: serde_json::Value,
    /// readWorkspaceTree's children (TreeNode camelCase serialization).
    pub local_children: Vec<LocalNodeDto>,
    /// relPath -> {username, deviceId, deviceName} as the GUI holds locks.
    pub locks: BTreeMap<String, crate::api::LockInfo>,
    pub changelist: ChangelistDto,
    /// path -> base hash | null (the synced manifest).
    pub base_hashes: BTreeMap<String, Option<String>>,
    pub me: MeDto,
    pub ignore_text: String,
    /// /api/users roster for holder display-name resolution (render_status).
    pub users: serde_json::Value,
}

#[derive(serde::Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ServerFileDto {
    pub hash: Option<String>,
    pub size_bytes: u64,
    pub head_revision: Option<u64>,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ComputeStatusResponse {
    /// Entries in the exact `cambium status --json` shape: {path, status, detail}.
    pub statuses: Vec<serde_json::Value>,
    /// Flattened depot tree (path -> file info) so JS keeps ONE flatten impl.
    pub server_files: BTreeMap<String, ServerFileDto>,
    /// Rule-5 vanished paths. The GUI ignores this (its external-deletion
    /// effect owns persistence); the field exists for contract completeness.
    pub vanished: Vec<String>,
}

/// Mirrors `executor::flatten_local_files` (files only; folders recurse) but
/// over owned `LocalNodeDto` nodes rather than `workspace::TreeNode`.
fn flatten_local(nodes: &[LocalNodeDto], prefix: &str, out: &mut BTreeMap<String, LocalFile>) {
    for node in nodes {
        let rel = if prefix.is_empty() { node.name.clone() } else { format!("{prefix}/{}", node.name) };
        match node.node_type.as_str() {
            "file" => {
                out.insert(
                    rel,
                    LocalFile {
                        hash: node.hash.clone(),
                        writable: node.writable.unwrap_or(false),
                        size_bytes: node.size_bytes.unwrap_or(0),
                    },
                );
            }
            "folder" => {
                if let Some(children) = &node.children {
                    flatten_local(children, &rel, out);
                }
            }
            _ => {}
        }
    }
}

pub fn compute_status_dto(inputs: StatusInputsDto) -> ComputeStatusResponse {
    let server_files = flatten_depot_tree(&inputs.depot_tree);
    let mut local_files = BTreeMap::new();
    flatten_local(&inputs.local_children, "", &mut local_files);
    let ignore = IgnoreMatcher::compile(&inputs.ignore_text);
    let changelist: Changelist = inputs.changelist.into();

    let result = compute_statuses(&StatusInputs {
        server_files: &server_files,
        local_files: &local_files,
        locks: &inputs.locks,
        changelist: &changelist,
        base_hashes: &inputs.base_hashes,
        me: (&inputs.me.username, &inputs.me.device_id),
        ignore: &ignore,
    });

    // ServerDataContext already exposes the roster unwrapped (`users: roster`,
    // a bare array) — it lacks the top-level `{users:[...]}` envelope the raw
    // `/api/users` response (and render_status/holder_display_name) expect.
    // Rebuilding the envelope here keeps render_status itself unchanged, so
    // the CLI (which does have the real envelope) and the GUI share one
    // implementation.
    let users_json = serde_json::json!({ "users": inputs.users });

    let statuses = result
        .statuses
        .iter()
        .map(|(path, status)| {
            let (_label, key, detail) = render_status(status, &users_json);
            serde_json::json!({ "path": path, "status": key, "detail": detail })
        })
        .collect();

    let server_files = server_files
        .into_iter()
        .map(|(path, f)| {
            (path, ServerFileDto { hash: f.hash, size_bytes: f.size_bytes, head_revision: f.head_revision })
        })
        .collect();

    ComputeStatusResponse { statuses, server_files, vanished: result.vanished }
}

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

    fn depot_tree_with(entries: &[(&str, &str, u64, u64)]) -> serde_json::Value {
        let children: Vec<_> = entries
            .iter()
            .map(|(name, hash, size, rev)| {
                serde_json::json!({"type": "file", "name": name, "hash": hash, "sizeBytes": size, "headRevision": rev})
            })
            .collect();
        serde_json::json!({"children": children})
    }

    fn local_node_file(name: &str, hash: &str) -> LocalNodeDto {
        LocalNodeDto {
            name: name.to_string(),
            node_type: "file".to_string(),
            size_bytes: Some(10),
            children: None,
            hash: Some(hash.to_string()),
            writable: Some(false),
        }
    }

    fn base_inputs() -> StatusInputsDto {
        StatusInputsDto {
            depot_tree: serde_json::json!({"children": []}),
            local_children: Vec::new(),
            locks: BTreeMap::new(),
            changelist: ChangelistDto { add: vec![], del: vec![], del_folders: vec![] },
            base_hashes: BTreeMap::new(),
            me: MeDto { username: "alice".to_string(), device_id: "dev1".to_string() },
            ignore_text: String::new(),
            users: serde_json::Value::Null,
        }
    }

    #[test]
    fn locked_by_other_resolves_display_name_from_users() {
        let mut inputs = base_inputs();
        inputs.depot_tree = depot_tree_with(&[("a.txt", "h1", 10, 3)]);
        inputs.locks.insert(
            "a.txt".to_string(),
            crate::api::LockInfo {
                username: "dana".to_string(),
                device_id: "dev9".to_string(),
                device_name: "Dana's PC".to_string(),
            },
        );
        inputs.users = serde_json::json!([{"username": "dana", "displayName": "Dana Scully"}]);

        let resp = compute_status_dto(inputs);
        let entry = resp.statuses.iter().find(|e| e["path"] == "a.txt").expect("entry present");
        assert_eq!(entry["status"], "lockedByOther");
        assert_eq!(entry["detail"]["holder"], "dana");
        assert_eq!(entry["detail"]["holderDisplayName"], "Dana Scully");
    }

    #[test]
    fn ignored_local_only_file_is_absent_while_sibling_shows_not_added() {
        let mut inputs = base_inputs();
        inputs.ignore_text = "*.tmp".to_string();
        inputs.local_children = vec![local_node_file("scratch.tmp", "h1"), local_node_file("real.txt", "h2")];

        let resp = compute_status_dto(inputs);
        assert!(resp.statuses.iter().all(|e| e["path"] != "scratch.tmp"));
        let real = resp.statuses.iter().find(|e| e["path"] == "real.txt").expect("entry present");
        assert_eq!(real["status"], "notAdded");
    }

    #[test]
    fn server_files_serialize_camel_case() {
        let mut inputs = base_inputs();
        inputs.depot_tree = depot_tree_with(&[("a.txt", "h1", 12, 4)]);

        let resp = compute_status_dto(inputs);
        assert_eq!(
            resp.server_files.get("a.txt"),
            Some(&ServerFileDto { hash: Some("h1".to_string()), size_bytes: 12, head_revision: Some(4) })
        );
        let json = serde_json::to_value(&resp).unwrap();
        let file = &json["serverFiles"]["a.txt"];
        assert_eq!(file["hash"], "h1");
        assert_eq!(file["sizeBytes"], 12);
        assert_eq!(file["headRevision"], 4);
    }

    #[test]
    fn vanished_manifest_file_lands_in_both_vanished_and_statuses() {
        let mut inputs = base_inputs();
        inputs.depot_tree = depot_tree_with(&[("Assets/gone.txt", "h1", 10, 3)]);
        inputs.base_hashes.insert("Assets/gone.txt".to_string(), Some("h1".to_string()));
        // Not in local_children: vanished from disk.

        let resp = compute_status_dto(inputs);
        assert_eq!(resp.vanished, vec!["Assets/gone.txt".to_string()]);
        let entry = resp.statuses.iter().find(|e| e["path"] == "Assets/gone.txt").expect("entry present");
        assert_eq!(entry["status"], "markedForDelete");
    }

    #[test]
    fn status_inputs_dto_deserializes_the_exact_gui_wire_shape() {
        let json = serde_json::json!({
            "depotTree": {"children": [
                {"type": "file", "name": "a.txt", "hash": "h1", "sizeBytes": 5, "headRevision": 1}
            ]},
            "localChildren": [
                {"name": "a.txt", "type": "file", "sizeBytes": 5, "hash": "h1", "writable": false}
            ],
            // Non-empty, exact camelCase wire shape for api::LockInfo
            // ({username, deviceId, deviceName}) — a serde drift here would
            // only fail at runtime, where the GUI's `.catch(() => null)`
            // silently blanks every status chip. Proven below by round-
            // tripping through compute_status_dto and checking the lock
            // actually produced a lockedByOther entry.
            "locks": {
                "a.txt": {"username": "dana", "deviceId": "dev9", "deviceName": "Dana's PC"}
            },
            "changelist": {"add": [], "del": [], "delFolders": []},
            "baseHashes": {},
            "me": {"username": "alice", "deviceId": "dev1"},
            "ignoreText": "",
            "users": []
        });
        let inputs: StatusInputsDto = serde_json::from_value(json).expect("deserializes");
        assert_eq!(inputs.local_children.len(), 1);
        assert_eq!(inputs.local_children[0].node_type, "file");
        assert_eq!(inputs.me.username, "alice");
        assert_eq!(inputs.locks.get("a.txt").map(|l| l.username.as_str()), Some("dana"));
        assert_eq!(inputs.locks.get("a.txt").map(|l| l.device_id.as_str()), Some("dev9"));
        assert_eq!(inputs.locks.get("a.txt").map(|l| l.device_name.as_str()), Some("Dana's PC"));

        let resp = compute_status_dto(inputs);
        let entry = resp.statuses.iter().find(|e| e["path"] == "a.txt").expect("entry present");
        assert_eq!(entry["status"], "lockedByOther");
        assert_eq!(entry["detail"]["holder"], "dana");
    }
}
