//! Apply / replay / subtract package permission grants in tool settings files
//! (today: Claude Code's `~/.claude/settings.json` and
//! `.claude/settings{,.local}.json`).

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

use anyhow::{bail, Context, Result};
use inquire::Select;
use serde_json::{json, Map, Value};

use crate::cli::{PermissionFile, PermissionFlags, PermissionScope};
use crate::fsx;
use crate::merge::{self, MergeStrategy};
use crate::registry::packages::PermissionGrant;
use crate::registry::Package;
use crate::state::PermissionRecord;
use crate::tools::{claude, Tool};
use crate::ui::style;

use super::yes;

/// Resolved target file + per-tool grant body for a single permission application.
#[derive(Debug, Clone)]
pub struct PermissionPlan {
    pub tool: Tool,
    pub path: PathBuf,
    /// JSON object that will be merged in (and later subtracted on remove).
    pub body: Value,
}

/// Effective scope after resolving flags / -y / install scope.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum EffectiveScope {
    Global,
    Project,
    Skip,
}

/// Build plans for every grant in `package` that targets a supported tool.
/// Returns an empty `Vec` if the package declares no permissions, or if every
/// grant targets a tool we don't know how to write settings for.
pub fn plan(
    package: &Package,
    scope: EffectiveScope,
    project_root: Option<&Path>,
    file_pref: PermissionFile,
) -> Result<Vec<PermissionPlan>> {
    if matches!(scope, EffectiveScope::Skip) {
        return Ok(Vec::new());
    }
    let mut plans = Vec::new();
    for grant in &package.permissions {
        if grant.is_empty() {
            continue;
        }
        let tools = applicable_tools(grant, package);
        let body = grant_body(grant);
        for tool in tools {
            let Some(path) = settings_path_for(tool, scope, project_root, file_pref)? else {
                continue;
            };
            plans.push(PermissionPlan {
                tool,
                path,
                body: body.clone(),
            });
        }
    }
    Ok(plans)
}

fn applicable_tools(grant: &PermissionGrant, package: &Package) -> Vec<Tool> {
    match grant.tool {
        Some(t) => vec![t],
        None => {
            if package.tools.is_empty() {
                vec![Tool::Claude]
            } else {
                package.tools.clone()
            }
        }
    }
}

fn grant_body(grant: &PermissionGrant) -> Value {
    let mut perms = Map::new();
    if !grant.allow.is_empty() {
        perms.insert("allow".into(), json!(grant.allow));
    }
    if !grant.ask.is_empty() {
        perms.insert("ask".into(), json!(grant.ask));
    }
    if !grant.deny.is_empty() {
        perms.insert("deny".into(), json!(grant.deny));
    }
    json!({ "permissions": Value::Object(perms) })
}

fn settings_path_for(
    tool: Tool,
    scope: EffectiveScope,
    project_root: Option<&Path>,
    file_pref: PermissionFile,
) -> Result<Option<PathBuf>> {
    match (tool, scope) {
        (Tool::Claude, EffectiveScope::Global) => Ok(Some(claude::user_settings_path()?)),
        (Tool::Claude, EffectiveScope::Project) => {
            let Some(root) = project_root else {
                bail!("project-scope permissions requested but not in a project");
            };
            let rel = match file_pref {
                PermissionFile::Local => claude::SETTINGS_LOCAL_PATH,
                PermissionFile::Shared => claude::SETTINGS_PATH,
            };
            Ok(Some(root.join(rel)))
        }
        // Other tools don't have a known settings file yet.
        _ => Ok(None),
    }
}

/// Resolve the user's effective scope, taking into account: flags, -y mode,
/// install scope context, and (in the absence of all of those) a prompt.
pub fn resolve_scope(
    flags: &PermissionFlags,
    install_is_project: bool,
    has_grants: bool,
) -> Result<EffectiveScope> {
    if !has_grants {
        return Ok(EffectiveScope::Skip);
    }
    if let Some(s) = flags.scope {
        return Ok(match s {
            PermissionScope::Global => EffectiveScope::Global,
            PermissionScope::Project => EffectiveScope::Project,
            PermissionScope::Skip => EffectiveScope::Skip,
        });
    }
    // No flag: install scope sets the default.
    let default = if install_is_project {
        EffectiveScope::Project
    } else {
        EffectiveScope::Global
    };
    if yes::enabled() {
        return Ok(default);
    }
    // Interactive: a global install just confirms. A project install asks
    // whether to apply globally too.
    if install_is_project {
        const PROJECT: &str = "this project (.claude/settings*.json)";
        const GLOBAL: &str = "globally (~/.claude/settings.json)";
        const SKIP: &str = "skip";
        let picked = Select::new(
            "Apply package permission rules where?",
            vec![PROJECT, GLOBAL, SKIP],
        )
        .prompt()?;
        Ok(match picked {
            GLOBAL => EffectiveScope::Global,
            SKIP => EffectiveScope::Skip,
            _ => EffectiveScope::Project,
        })
    } else {
        const GLOBAL: &str = "yes (~/.claude/settings.json)";
        const SKIP: &str = "skip";
        let picked = Select::new(
            "Apply package permission rules globally?",
            vec![GLOBAL, SKIP],
        )
        .prompt()?;
        Ok(if picked == GLOBAL {
            EffectiveScope::Global
        } else {
            EffectiveScope::Skip
        })
    }
}

/// Apply each plan against the destination file. Existing content is merged
/// with `MergeStrategy::Append` (idempotent — dedupes on arrays).
pub fn apply(plans: &[PermissionPlan]) -> Result<Vec<PermissionRecord>> {
    let mut records = Vec::with_capacity(plans.len());
    for plan in plans {
        let merged = merge_into_file(&plan.path, &plan.body)?;
        let pretty = serde_json::to_string_pretty(&merged)?;
        fsx::atomic_write(&plan.path, &format!("{pretty}\n"))?;
        records.push(PermissionRecord {
            tool: plan.tool,
            path: plan.path.clone(),
            applied: plan.body.clone(),
        });
        println!(
            "  {} permissions → {}",
            style::ok_mark(),
            plan.path.display()
        );
    }
    Ok(records)
}

/// Re-apply previously-recorded grants without prompting. Used by `sync` to
/// keep settings files consistent with state.
pub fn replay(records: &[PermissionRecord]) -> Result<()> {
    for rec in records {
        let merged = merge_into_file(&rec.path, &rec.applied)?;
        let pretty = serde_json::to_string_pretty(&merged)?;
        fsx::atomic_write(&rec.path, &format!("{pretty}\n"))?;
    }
    Ok(())
}

/// Subtract previously-applied grants when a package is removed.
pub fn unapply(records: &[PermissionRecord]) -> Result<()> {
    for rec in records {
        if !rec.path.exists() {
            continue;
        }
        let text = fs::read_to_string(&rec.path)
            .with_context(|| format!("reading {}", rec.path.display()))?;
        let current: Value = if text.trim().is_empty() {
            Value::Object(Map::new())
        } else {
            serde_json::from_str(&text)
                .with_context(|| format!("parsing {}", rec.path.display()))?
        };
        let after = merge::json::subtract(current, &rec.applied);
        let after = prune_empty(after);
        if is_effectively_empty(&after) {
            // Don't delete the file outright (user may have other settings we
            // can't see), just write an empty object.
            fsx::atomic_write(&rec.path, "{}\n")?;
        } else {
            let pretty = serde_json::to_string_pretty(&after)?;
            fsx::atomic_write(&rec.path, &format!("{pretty}\n"))?;
        }
        println!(
            "  {} permissions ← removed from {}",
            style::ok_mark(),
            rec.path.display()
        );
    }
    Ok(())
}

fn merge_into_file(path: &Path, body: &Value) -> Result<Value> {
    let current = if path.exists() {
        let text =
            fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
        if text.trim().is_empty() {
            Value::Object(Map::new())
        } else {
            serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?
        }
    } else {
        Value::Object(Map::new())
    };
    Ok(merge::json::merge(
        current,
        body.clone(),
        MergeStrategy::Append,
    ))
}

/// Recursively drop empty objects/arrays so subtraction doesn't leave behind
/// hollow `{"permissions": {"allow": []}}` shells.
fn prune_empty(v: Value) -> Value {
    match v {
        Value::Object(map) => {
            let mut out = Map::new();
            for (k, val) in map {
                let pruned = prune_empty(val);
                if !is_effectively_empty(&pruned) {
                    out.insert(k, pruned);
                }
            }
            Value::Object(out)
        }
        Value::Array(arr) => {
            // arrays of primitives stay; arrays of empties get filtered.
            let kept: Vec<Value> = arr
                .into_iter()
                .map(prune_empty)
                .filter(|v| !is_effectively_empty(v))
                .collect();
            Value::Array(kept)
        }
        other => other,
    }
}

fn is_effectively_empty(v: &Value) -> bool {
    match v {
        Value::Object(m) => m.is_empty(),
        Value::Array(a) => a.is_empty(),
        Value::Null => true,
        _ => false,
    }
}

/// Render a one-line summary of the proposed plans for confirmation prompts.
pub fn summarize(plans: &[PermissionPlan]) -> String {
    let mut counts: Vec<String> = Vec::new();
    for plan in plans {
        let mut allow = 0usize;
        let mut ask = 0usize;
        let mut deny = 0usize;
        if let Some(perms) = plan.body.get("permissions") {
            allow = perms
                .get("allow")
                .and_then(Value::as_array)
                .map(Vec::len)
                .unwrap_or(0);
            ask = perms
                .get("ask")
                .and_then(Value::as_array)
                .map(Vec::len)
                .unwrap_or(0);
            deny = perms
                .get("deny")
                .and_then(Value::as_array)
                .map(Vec::len)
                .unwrap_or(0);
        }
        let mut parts = Vec::new();
        if allow > 0 {
            parts.push(format!("{allow} allow"));
        }
        if ask > 0 {
            parts.push(format!("{ask} ask"));
        }
        if deny > 0 {
            parts.push(format!("{deny} deny"));
        }
        counts.push(format!("{} ({})", plan.path.display(), parts.join(", ")));
    }
    counts.join("; ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::registry::packages::{InstructionsBlock, PermissionGrant};
    use crate::registry::Scope;
    use tempfile::tempdir;

    fn pkg(perms: Vec<PermissionGrant>) -> Package {
        Package {
            name: "demo".into(),
            description: None,
            homepage: None,
            tools: vec![Tool::Claude],
            scope: Scope::Both,
            depends_on: Vec::new(),
            artifacts: Vec::new(),
            verify: None,
            prereq: Vec::new(),
            install: Vec::new(),
            update: None,
            uninstall: None,
            instructions: Vec::<InstructionsBlock>::new(),
            permissions: perms,
        }
    }

    #[test]
    fn plan_skips_when_scope_is_skip() {
        let p = pkg(vec![PermissionGrant {
            tool: None,
            allow: vec!["Bash(foo)".into()],
            ask: vec![],
            deny: vec![],
        }]);
        let out = plan(&p, EffectiveScope::Skip, None, PermissionFile::Shared).unwrap();
        assert!(out.is_empty());
    }

    #[test]
    fn plan_routes_project_to_shared_default() {
        let tmp = tempdir().unwrap();
        let p = pkg(vec![PermissionGrant {
            tool: None,
            allow: vec!["Bash(foo)".into()],
            ask: vec![],
            deny: vec![],
        }]);
        let plans = plan(
            &p,
            EffectiveScope::Project,
            Some(tmp.path()),
            PermissionFile::Shared,
        )
        .unwrap();
        assert_eq!(plans.len(), 1);
        assert_eq!(plans[0].tool, Tool::Claude);
        assert_eq!(plans[0].path, tmp.path().join(".claude/settings.json"));
    }

    #[test]
    fn plan_routes_project_to_local_when_requested() {
        let tmp = tempdir().unwrap();
        let p = pkg(vec![PermissionGrant {
            tool: None,
            allow: vec!["Bash(foo)".into()],
            ask: vec![],
            deny: vec![],
        }]);
        let plans = plan(
            &p,
            EffectiveScope::Project,
            Some(tmp.path()),
            PermissionFile::Local,
        )
        .unwrap();
        assert_eq!(
            plans[0].path,
            tmp.path().join(".claude/settings.local.json")
        );
    }

    #[test]
    fn apply_then_unapply_round_trips_to_empty_object() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("settings.json");
        let body = json!({"permissions": {"allow": ["Bash(foo)", "Bash(bar)"]}});
        let recs = apply(&[PermissionPlan {
            tool: Tool::Claude,
            path: path.clone(),
            body: body.clone(),
        }])
        .unwrap();
        let written: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(written, body);

        unapply(&recs).unwrap();
        let after: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(after, json!({}));
    }

    #[test]
    fn apply_is_idempotent_and_dedupes_with_existing_entries() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("settings.json");
        fs::write(
            &path,
            r#"{"permissions": {"allow": ["Bash(foo)", "Bash(existing)"]}}"#,
        )
        .unwrap();
        let body = json!({"permissions": {"allow": ["Bash(foo)", "Bash(new)"]}});
        apply(&[PermissionPlan {
            tool: Tool::Claude,
            path: path.clone(),
            body: body.clone(),
        }])
        .unwrap();
        // Apply again — no duplicates.
        apply(&[PermissionPlan {
            tool: Tool::Claude,
            path: path.clone(),
            body,
        }])
        .unwrap();
        let written: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        let allow = written["permissions"]["allow"].as_array().unwrap();
        assert_eq!(allow.len(), 3);
        assert!(allow.iter().any(|v| v == "Bash(foo)"));
        assert!(allow.iter().any(|v| v == "Bash(existing)"));
        assert!(allow.iter().any(|v| v == "Bash(new)"));
    }

    #[test]
    fn unapply_preserves_unrelated_user_entries() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("settings.json");
        let body = json!({"permissions": {"allow": ["Bash(mine)"]}});
        apply(&[PermissionPlan {
            tool: Tool::Claude,
            path: path.clone(),
            body: body.clone(),
        }])
        .unwrap();
        // User adds their own entry by hand.
        let current: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        let mut current = current;
        current["permissions"]["allow"]
            .as_array_mut()
            .unwrap()
            .push(json!("Bash(user-added)"));
        fs::write(&path, serde_json::to_string(&current).unwrap()).unwrap();

        unapply(&[PermissionRecord {
            tool: Tool::Claude,
            path: path.clone(),
            applied: body,
        }])
        .unwrap();
        let after: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(
            after,
            json!({"permissions": {"allow": ["Bash(user-added)"]}})
        );
    }

    #[test]
    fn replay_is_idempotent() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("settings.json");
        let body = json!({"permissions": {"allow": ["Bash(foo)"]}});
        let recs = apply(&[PermissionPlan {
            tool: Tool::Claude,
            path: path.clone(),
            body,
        }])
        .unwrap();
        replay(&recs).unwrap();
        replay(&recs).unwrap();
        let after: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        let allow = after["permissions"]["allow"].as_array().unwrap();
        assert_eq!(allow.len(), 1);
    }
}
