//! The single clap grammar shared by the `cambium` CLI (argv) and the
//! desktop console panel (a typed line, split with `shlex`). Both front
//! ends parse into the same `Cli`/`Cmd` tree so the two surfaces can never
//! drift — this tree is the authoritative grammar, and the published
//! command reference
//! implements.
//!
//! The executor (Tasks 10-15) is not wired up yet: every verb currently
//! dispatches through `run_not_wired`, which returns a `CommandOutput`
//! saying so. Task 17 verifies none remain.

pub mod output;

pub use output::CommandOutput;

#[derive(clap::Parser, Debug)]
#[command(
    name = "cambium",
    version,
    about = "Cambium-CLI — command-line client for Cambium",
    disable_colored_help = true
)]
pub struct Cli {
    #[arg(long, global = true)]
    pub json: bool,
    #[arg(short = 'p', long, global = true)]
    pub project: Option<String>,
    #[command(subcommand)]
    pub cmd: Cmd,
}

#[derive(clap::Subcommand, Debug)]
pub enum Cmd {
    Login {
        #[arg(long)]
        server: Option<String>,
        #[arg(long)]
        user: Option<String>,
        /// Store a device-bound token on this machine so later commands don't prompt.
        #[arg(long)]
        remember: bool,
    },
    Logout,
    Trust {
        host: Option<String>,
        #[arg(long)]
        fingerprint: Option<String>,
    },
    Whoami,
    Projects,
    Link {
        project: String,
        folder: String,
    },
    Use {
        project: String,
    },
    Status {
        paths: Vec<String>,
    },
    Locks {
        paths: Vec<String>,
    },
    Checkout {
        paths: Vec<String>,
    },
    Add {
        paths: Vec<String>,
    },
    Delete {
        paths: Vec<String>,
    },
    Revert {
        paths: Vec<String>,
        #[arg(short = 'f')]
        force: bool,
    },
    Submit {
        paths: Vec<String>,
        #[arg(short = 'm', long)]
        message: String,
    },
    Pull {
        paths: Vec<String>,
        #[arg(short = 'f')]
        force: bool,
    },
    Rollback {
        path: String,
        revision: u64,
        #[arg(short = 'f')]
        force: bool,
    },
    Unlock {
        #[arg(short = 'f')]
        force: bool,
        paths: Vec<String>,
    },
    Addproject {
        name: String,
    },
    Delproject {
        project: String,
        /// Mandatory wherever we can't prompt (the desktop console, pipes,
        /// CI). The CLI asks for the project name instead when it has a
        /// terminal — see cambium-cli's main.rs.
        #[arg(short = 'f')]
        force: bool,
    },
    Adduser {
        handle: String,
        password: String,
    },
    Deluser {
        handle: String,
    },
    Password {
        handle: String,
        password: String,
    },
    Setrole {
        handle: String,
        role: String,
    },
    Assignmember {
        handle: String,
        project: String,
    },
    Removemember {
        handle: String,
        project: String,
    },
}

/// Console + CLI both come through here. `line` has no binary name.
///
/// Splits `line` the way a shell would (`shlex::split` — honors quotes so
/// `submit -m "two words"` keeps the space), prepends the program name
/// clap expects at argv[0], then parses. `shlex::split` returns `None` on
/// unbalanced quotes, which we turn into an actionable error rather than a
/// silent empty parse.
///
/// Clap's own parse errors (unknown verb, missing required arg, unknown
/// flag, ...) are rendered through `err.render()` and returned as `Err`.
/// `disable_colored_help` on `Cli` keeps `--help`/usage output free of ANSI
/// color codes; clap's default `ColorChoice::Auto` additionally means
/// non-terminal output (tests, pipes) never gets colored to begin with.
pub fn parse_line(line: &str) -> Result<Cli, String> {
    use clap::Parser;

    let args = shlex::split(line).ok_or_else(|| "unbalanced quotes".to_string())?;

    let mut argv = Vec::with_capacity(args.len() + 1);
    argv.push("cambium".to_string());
    argv.extend(args);

    Cli::try_parse_from(argv).map_err(|err| err.render().to_string())
}

/// Maps a parsed `Cmd` to the verb string a user typed (matches the
/// subcommand names clap derives: lowercase variant name).
fn verb_name(cmd: &Cmd) -> &'static str {
    match cmd {
        Cmd::Login { .. } => "login",
        Cmd::Logout => "logout",
        Cmd::Trust { .. } => "trust",
        Cmd::Whoami => "whoami",
        Cmd::Projects => "projects",
        Cmd::Link { .. } => "link",
        Cmd::Use { .. } => "use",
        Cmd::Status { .. } => "status",
        Cmd::Locks { .. } => "locks",
        Cmd::Checkout { .. } => "checkout",
        Cmd::Add { .. } => "add",
        Cmd::Delete { .. } => "delete",
        Cmd::Revert { .. } => "revert",
        Cmd::Submit { .. } => "submit",
        Cmd::Pull { .. } => "pull",
        Cmd::Rollback { .. } => "rollback",
        Cmd::Unlock { .. } => "unlock",
        Cmd::Addproject { .. } => "addproject",
        Cmd::Delproject { .. } => "delproject",
        Cmd::Adduser { .. } => "adduser",
        Cmd::Deluser { .. } => "deluser",
        Cmd::Password { .. } => "password",
        Cmd::Setrole { .. } => "setrole",
        Cmd::Assignmember { .. } => "assignmember",
        Cmd::Removemember { .. } => "removemember",
    }
}

/// Placeholder result for a verb whose executor hasn't landed yet.
fn todo_verb(verb: &str) -> CommandOutput {
    CommandOutput {
        ok: false,
        code: 1,
        human: format!("'{verb}' is not wired up yet (see plan tasks 10-15)"),
        data: serde_json::Value::Null,
        changed: Vec::new(),
    }
}

/// Dispatch placeholder used until Tasks 10-15 land the real executor.
/// Named so those tasks can grep-and-replace this call with
/// `executor::run(cli, ctx)`. Task 17 verifies no verb still routes here.
pub fn run_not_wired(cli: &Cli) -> CommandOutput {
    todo_verb(verb_name(&cli.cmd))
}

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

    /// One row of the parse-conformance table from the command reference /
    /// the task-9 brief: does `line` parse (`ok_expected == true`) or fail
    /// (`ok_expected == false`)?
    fn case(line: &str, ok_expected: bool) {
        let result = parse_line(line);
        assert_eq!(
            result.is_ok(),
            ok_expected,
            "line {line:?}: expected ok={ok_expected}, got {result:?}"
        );
    }

    #[test]
    fn parse_conformance_table() {
        // --- OK ---
        case("status", true);
        case("status ./Assets", true);
        case("status //Assets/rig.blend extra/path", true);
        case(r#"submit -m "first pass at the rig""#, true);
        case("submit ./Assets -m msg", true);
        case("checkout //Assets/rig.blend", true);
        case("revert -f", true);
        case("pull -f ./a", true);
        case("rollback //a.txt 3", true);
        case("rollback //a.txt 3 -f", true);
        case("unlock -f //a.txt", true);
        case("login --server localhost:8788 --user @alice", true);
        case("login --server localhost:8788 --user @alice --remember", true);
        case("link alpha /tmp/ws", true);
        case("use alpha", true);
        case("trust localhost:8788 --fingerprint AB:CD", true);
        case("adduser @robin hunter2", true);
        case("setrole @robin admin", true);
        case("assignmember @robin alpha", true);
        case("addproject alpha", true);
        case(r#"addproject "Concept Art""#, true);
        case("delproject alpha", true); // parses; -f is enforced by the executor
        case("delproject alpha -f", true);

        // --- FAIL ---
        case("addproject", false); // missing name
        case("delproject", false); // missing name
        case("addproject alpha extra", false); // takes exactly one name
        case("submit", false); // missing required -m
        // `delete` with no target is a PARSE PASS but an EXEC failure
        // (per the command reference: "delete has no default"); assert parse OK.
        case("delete", true);
        case("rollback //a.txt", false); // missing revision
        case("diff ./x", false); // not in the grammar (unknown verb)
        case("log", false); // not in the grammar (unknown verb)
        case("frobnicate", false); // unknown verb
        case("checkout --bogus-flag x", false); // unknown flag
    }

    #[test]
    fn delproject_dash_f_sets_force() {
        // The console's only confirmation is this flag, so the grammar has to
        // carry it; without -f the executor refuses rather than deleting.
        let cli = parse_line("delproject alpha -f").expect("delproject -f should parse");
        match cli.cmd {
            Cmd::Delproject { ref project, force } => {
                assert_eq!(project, "alpha");
                assert!(force);
            }
            other => panic!("expected Delproject, got {other:?}"),
        }

        let cli = parse_line("delproject alpha").expect("delproject should parse");
        match cli.cmd {
            Cmd::Delproject { force, .. } => assert!(!force),
            other => panic!("expected Delproject, got {other:?}"),
        }
    }

    #[test]
    fn addproject_keeps_a_quoted_name_whole() {
        let cli = parse_line(r#"addproject "Concept Art""#).expect("should parse");
        match cli.cmd {
            Cmd::Addproject { ref name } => assert_eq!(name, "Concept Art"),
            other => panic!("expected Addproject, got {other:?}"),
        }
    }

    #[test]
    fn revert_dash_f_sets_force() {
        let cli = parse_line("revert -f").expect("revert -f should parse");
        match cli.cmd {
            Cmd::Revert { force, ref paths } => {
                assert!(force);
                assert!(paths.is_empty());
            }
            other => panic!("expected Cmd::Revert, got {other:?}"),
        }
    }

    #[test]
    fn unlock_dash_f_before_paths_sets_force_and_collects_paths() {
        // The documented syntax is `unlock -f <path>...` — the doc order
        // puts -f before paths, but clap accepts flags positioned anywhere.
        let cli = parse_line("unlock -f //a.txt").expect("unlock -f //a.txt should parse");
        match cli.cmd {
            Cmd::Unlock { force, paths } => {
                assert!(force);
                assert_eq!(paths, vec!["//a.txt".to_string()]);
            }
            other => panic!("expected Cmd::Unlock, got {other:?}"),
        }
    }

    #[test]
    fn submit_message_survives_shlex_quoting() {
        let cli = parse_line(r#"submit -m "two words""#).expect("should parse");
        match cli.cmd {
            Cmd::Submit { message, .. } => assert_eq!(message, "two words"),
            other => panic!("expected Cmd::Submit, got {other:?}"),
        }
    }

    #[test]
    fn unknown_command_error_names_the_verb() {
        let err = parse_line("frobnicate").expect_err("frobnicate is not a verb");
        assert!(
            err.contains("frobnicate"),
            "expected error to mention the unknown verb, got: {err}"
        );
    }

    #[test]
    fn parse_error_output_has_no_ansi_escapes() {
        let err = parse_line("frobnicate").expect_err("frobnicate is not a verb");
        assert!(
            !err.contains('\u{1b}'),
            "parse error output contains an ANSI escape code: {err:?}"
        );

        let missing_arg_err = parse_line("submit").expect_err("submit needs -m");
        assert!(
            !missing_arg_err.contains('\u{1b}'),
            "parse error output contains an ANSI escape code: {missing_arg_err:?}"
        );
    }

    #[test]
    fn unbalanced_quotes_is_an_actionable_error() {
        let err = parse_line(r#"submit -m "unterminated"#).expect_err("unbalanced quotes");
        assert!(
            err.to_lowercase().contains("quote"),
            "expected an actionable message about quoting, got: {err}"
        );
    }

    #[test]
    fn run_not_wired_reports_the_verb_and_refused_exit_code() {
        let cli = parse_line("status").expect("status should parse");
        let out = run_not_wired(&cli);
        assert!(!out.ok);
        assert_eq!(out.code, 1);
        assert!(out.human.contains("status"));
        assert!(out.human.contains("not wired up yet"));
        assert!(out.changed.is_empty());
    }

    #[test]
    fn command_output_to_json_line_shape() {
        let ok_out = CommandOutput {
            ok: true,
            code: 0,
            human: "done".to_string(),
            data: serde_json::json!({"n": 1}),
            changed: vec!["files"],
        };
        let json: serde_json::Value = serde_json::from_str(&ok_out.to_json_line()).unwrap();
        assert_eq!(json["ok"], true);
        assert_eq!(json["code"], 0);
        assert_eq!(json["data"]["n"], 1);
        assert!(json["error"].is_null());

        let err_out = CommandOutput {
            ok: false,
            code: 1,
            human: "refusing: pass -f".to_string(),
            data: serde_json::Value::Null,
            changed: vec![],
        };
        let json: serde_json::Value = serde_json::from_str(&err_out.to_json_line()).unwrap();
        assert_eq!(json["ok"], false);
        assert_eq!(json["code"], 1);
        assert_eq!(json["error"], "refusing: pass -f");
    }
}
