//! Claude Code permission projector (lot LP3-2).
//!
//! Produces the `.claude/settings.local.json` seed written into an agent run dir:
//! full project autonomy (`bypassPermissions` + broad Read/Edit/Write/Bash) with
//! the project root granted as an additional working directory, while keeping
//! destructive/out-of-project commands denied. The translation is extracted
//! verbatim from the former `claude_settings_seed` in `lifecycle.rs`.

use domain::permission::{
    Capability, CommandMatcher, Effect, EffectivePermissions, PermissionProjection,
    PermissionProjector, PermissionRule, Posture, ProjectedFile, ProjectionContext, ProjectorKey,
};

use super::json_escape;

/// Run-dir-relative path of the owned Claude settings seed.
const SETTINGS_REL_PATH: &str = ".claude/settings.local.json";

/// Projects [`EffectivePermissions`] into Claude Code's `settings.local.json`.
///
/// Pure: `project` only computes the JSON; the launch path materialises it. A
/// `Replace`-owned file (clobbered at launch, removed on swap-away).
#[derive(Debug, Default, Clone, Copy)]
pub struct ClaudePermissionProjector;

impl PermissionProjector for ClaudePermissionProjector {
    fn key(&self) -> ProjectorKey {
        ProjectorKey::Claude
    }

    fn project(
        &self,
        eff: Option<&EffectivePermissions>,
        ctx: &ProjectionContext,
    ) -> PermissionProjection {
        // Product invariant: nothing posed ⇒ nothing projected (native prompting).
        let Some(_) = eff else {
            return PermissionProjection::empty();
        };
        let contents = claude_settings_seed(ctx.project_root, eff);
        PermissionProjection {
            files: vec![ProjectedFile::Replace {
                rel_path: SETTINGS_REL_PATH.to_owned(),
                contents,
            }],
            args: Vec::new(),
            env: Vec::new(),
        }
    }

    fn owned_replace_paths(&self) -> Vec<String> {
        vec![SETTINGS_REL_PATH.to_owned()]
    }
}

/// Builds the Claude Code permission seed. `project_root` is embedded verbatim
/// (JSON-escaped) and granted as an additional working directory, since the cwd is
/// the run dir and the agent works on the root above it.
fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermissions>) -> String {
    let root = json_escape(project_root);
    let default_mode = match permissions.map(EffectivePermissions::fallback) {
        Some(Posture::Deny) => "plan",
        Some(Posture::Ask) => "acceptEdits",
        Some(Posture::Allow) | None => "bypassPermissions",
    };
    let allow = claude_permission_entries(permissions, Effect::Allow);
    let deny = claude_permission_entries(permissions, Effect::Deny);
    let default_allow = [
        "Read".to_owned(),
        "Edit".to_owned(),
        "Write".to_owned(),
        "Bash".to_owned(),
    ];
    let allow = json_string_array(if allow.is_empty() {
        &default_allow
    } else {
        &allow
    });
    let deny = json_string_array(&merge_default_deny(deny));
    format!(
        r#"{{
  "permissions": {{
    "defaultMode": "{default_mode}",
    "additionalDirectories": [
      "{root}"
    ],
    "allow": {allow},
    "deny": {deny}
  }},
  "skipDangerousModePermissionPrompt": true,
  "enabledMcpjsonServers": ["idea"],
  "sandbox": {{
    "enabled": false
  }}
}}
"#
    )
}

fn merge_default_deny(mut deny: Vec<String>) -> Vec<String> {
    for item in [
        "Bash(sudo *)",
        "Bash(rm -rf /)",
        "Bash(rm -rf /*)",
        "Bash(rm -rf ~)",
        "Bash(rm -rf ~/)",
        "Bash(rm -rf ~/*)",
        "Bash(rm -rf $HOME*)",
        "Bash(mkfs*)",
        "Bash(dd if=*)",
        "Bash(shutdown*)",
        "Bash(reboot*)",
    ] {
        if !deny.iter().any(|existing| existing == item) {
            deny.push(item.to_owned());
        }
    }
    deny
}

fn claude_permission_entries(
    permissions: Option<&EffectivePermissions>,
    effect: Effect,
) -> Vec<String> {
    let Some(permissions) = permissions else {
        return Vec::new();
    };
    let mut out = Vec::new();
    for rule in permissions.rules() {
        if rule.effect() != effect {
            continue;
        }
        match rule.capability() {
            Capability::Read => push_path_entries(&mut out, "Read", rule),
            Capability::Write => {
                push_path_entries(&mut out, "Edit", rule);
                push_path_entries(&mut out, "Write", rule);
            }
            Capability::Delete => push_delete_entries(&mut out, rule),
            Capability::ExecuteBash => push_bash_entries(&mut out, rule),
        }
    }
    out
}

fn push_path_entries(out: &mut Vec<String>, capability: &str, rule: &PermissionRule) {
    if rule.paths().is_empty() {
        out.push(capability.to_owned());
        return;
    }
    for glob in rule.paths().globs() {
        out.push(format!("{capability}({})", glob.pattern()));
    }
}

fn push_delete_entries(out: &mut Vec<String>, rule: &PermissionRule) {
    if rule.paths().is_empty() {
        out.push("Bash(rm *)".to_owned());
        return;
    }
    for glob in rule.paths().globs() {
        out.push(format!("Bash(rm {})", glob.pattern()));
    }
}

fn push_bash_entries(out: &mut Vec<String>, rule: &PermissionRule) {
    if rule.commands().is_empty() {
        out.push("Bash".to_owned());
        return;
    }
    for cmd in rule.commands() {
        if cmd.effect != rule.effect() {
            continue;
        }
        out.push(format!("Bash({})", command_matcher_pattern(&cmd.matcher)));
    }
}

fn command_matcher_pattern(matcher: &CommandMatcher) -> String {
    match matcher {
        CommandMatcher::Exact(value) => value.clone(),
        CommandMatcher::Prefix(value) => format!("{value}*"),
        CommandMatcher::Glob(glob) => glob.pattern().to_owned(),
    }
}

fn json_string_array(items: &[String]) -> String {
    if items.is_empty() {
        return "[]".to_owned();
    }
    let body = items
        .iter()
        .map(|item| format!("      \"{}\"", json_escape(item)))
        .collect::<Vec<_>>()
        .join(",\n");
    format!("[\n{body}\n    ]")
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::permission::{resolve, PathScope, PermissionSet};
    use serde_json::Value;

    fn ctx<'a>(root: &'a str, run_dir: &'a str) -> ProjectionContext<'a> {
        ProjectionContext {
            project_root: root,
            run_dir,
        }
    }

    fn path_scope(patterns: &[&str]) -> PathScope {
        PathScope::new(patterns.iter().map(ToString::to_string)).unwrap()
    }

    /// Builds an [`EffectivePermissions`] from a single (project) set via the
    /// domain API, exactly like the `permission` unit tests do.
    fn eff_with(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
        resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap()
    }

    /// Projects and returns the parsed `settings.local.json` value, asserting the
    /// projection's structural contract (1 Replace file, no args/env) along the way.
    fn project_json(eff: &EffectivePermissions, root: &str) -> Value {
        let proj = ClaudePermissionProjector.project(Some(eff), &ctx(root, "/run/agent"));
        assert!(proj.args.is_empty(), "Claude projection carries no args");
        assert!(proj.env.is_empty(), "Claude projection carries no env");
        assert_eq!(proj.files.len(), 1, "exactly one file projected");
        match &proj.files[0] {
            ProjectedFile::Replace { rel_path, contents } => {
                assert_eq!(rel_path, SETTINGS_REL_PATH);
                serde_json::from_str(contents).expect("the produced settings is valid JSON")
            }
            ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"),
        }
    }

    fn str_array(value: &Value) -> Vec<String> {
        value
            .as_array()
            .expect("array")
            .iter()
            .map(|v| v.as_str().expect("string").to_owned())
            .collect()
    }

    // ---- product invariant + ownership ----------------------------------

    #[test]
    fn project_none_is_empty() {
        let proj = ClaudePermissionProjector.project(None, &ctx("/proj", "/run"));
        assert!(proj.files.is_empty());
        assert!(proj.args.is_empty());
        assert!(proj.env.is_empty());
    }

    #[test]
    fn owned_replace_paths_is_the_settings_file() {
        assert_eq!(
            ClaudePermissionProjector.owned_replace_paths(),
            vec![SETTINGS_REL_PATH.to_owned()]
        );
    }

    // ---- (1) posture → defaultMode --------------------------------------

    #[test]
    fn default_mode_maps_each_posture() {
        for (posture, mode) in [
            (Posture::Allow, "bypassPermissions"),
            (Posture::Ask, "acceptEdits"),
            (Posture::Deny, "plan"),
        ] {
            let json = project_json(&eff_with(vec![], posture), "/proj");
            assert_eq!(
                json["permissions"]["defaultMode"], mode,
                "posture {posture:?} should map to defaultMode {mode}"
            );
        }
    }

    // ---- (2) deny-wins: deny entry surfaces in the deny list -------------

    #[test]
    fn specific_deny_with_broad_allow_appears_in_deny_list() {
        let rules = vec![
            PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"]))
                .unwrap(),
            PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"])).unwrap(),
        ];
        let json = project_json(&eff_with(rules, Posture::Allow), "/proj");

        let deny = str_array(&json["permissions"]["deny"]);
        // A Write capability fans out to both Edit(..) and Write(..) entries.
        assert!(
            deny.contains(&"Edit(.ideai/**)".to_owned()),
            "deny={deny:?}"
        );
        assert!(
            deny.contains(&"Write(.ideai/**)".to_owned()),
            "deny={deny:?}"
        );

        let allow = str_array(&json["permissions"]["allow"]);
        assert!(
            allow.contains(&"Edit(**)".to_owned()) && allow.contains(&"Write(**)".to_owned()),
            "the broad allow stays in the allow list; allow={allow:?}"
        );
    }

    // ---- (3) additionalDirectories carries the (escaped) project root ----

    #[test]
    fn additional_directories_contains_project_root_escaped() {
        // A Windows-ish path with a backslash AND a quote exercises JSON escaping;
        // parsing it back must yield the original raw path verbatim.
        let root = r#"C:\Users\a"b\proj"#;
        let json = project_json(&eff_with(vec![], Posture::Allow), root);
        let dirs = str_array(&json["permissions"]["additionalDirectories"]);
        assert_eq!(dirs, vec![root.to_owned()]);
    }

    // ---- (4) hard-coded destructive guardrails --------------------------

    #[test]
    fn default_deny_guardrails_are_present() {
        let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
        let deny = str_array(&json["permissions"]["deny"]);
        for guard in [
            "Bash(sudo *)",
            "Bash(rm -rf /)",
            "Bash(rm -rf ~)",
            "Bash(rm -rf $HOME*)",
            "Bash(mkfs*)",
            "Bash(dd if=*)",
            "Bash(shutdown*)",
            "Bash(reboot*)",
        ] {
            assert!(
                deny.contains(&guard.to_owned()),
                "missing guardrail {guard}; deny={deny:?}"
            );
        }
    }

    // ---- (5) valid JSON + expected static shape -------------------------

    #[test]
    fn produced_settings_has_expected_static_shape() {
        // `project_json` already proved the document parses; assert the fixed keys.
        let json = project_json(&eff_with(vec![], Posture::Ask), "/proj");
        assert_eq!(json["enabledMcpjsonServers"][0], "idea");
        assert_eq!(json["skipDangerousModePermissionPrompt"], true);
        assert_eq!(json["sandbox"]["enabled"], false);
    }

    #[test]
    fn empty_rules_fall_back_to_broad_default_allow() {
        let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
        let allow = str_array(&json["permissions"]["allow"]);
        assert_eq!(
            allow,
            vec![
                "Read".to_owned(),
                "Edit".to_owned(),
                "Write".to_owned(),
                "Bash".to_owned()
            ]
        );
    }
}
