//! Codex CLI permission projector (lot LP3-2).
//!
//! Produces the **permission-relevant** part of Codex's `config.toml`
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the
//! project root as an additional writable directory (`--add-dir`). The
//! posture→mode derivation is extracted verbatim from the former
//! `codex_sandbox_mode` / `codex_approval_policy` / `apply_codex_cli_permission_args`
//! in `lifecycle.rs`.
//!
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
//! concerns, not permissions). The projector therefore emits a
//! [`ProjectedFile::MergeToml`] limited to the two permission keys it manages —
//! everything else in the file is preserved by the fold, and the file is **never**
//! deleted on swap (hence an empty `owned_replace_paths`).

use domain::permission::{
    EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
    ProjectionContext, ProjectorKey,
};

use super::toml_string;

/// Run-dir-relative path of Codex's `config.toml`. Codex reads its config from
/// `$CODEX_HOME/config.toml`; IdeA isolates `CODEX_HOME` to `{runDir}/.codex`, so
/// the file lives at `.codex/config.toml` relative to the run dir.
const CONFIG_REL_PATH: &str = ".codex/config.toml";

/// The two top-level keys this projector manages in `config.toml`. Everything else
/// (MCP table, trust entries, user keys) is preserved by the merge.
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];

/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args.
///
/// Pure: `project` only computes the plan; the launch path merges the TOML fragment
/// and appends the args.
#[derive(Debug, Default, Clone, Copy)]
pub struct CodexPermissionProjector;

impl PermissionProjector for CodexPermissionProjector {
    fn key(&self) -> ProjectorKey {
        ProjectorKey::Codex
    }

    fn project(
        &self,
        eff: Option<&EffectivePermissions>,
        ctx: &ProjectionContext,
    ) -> PermissionProjection {
        // Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
        // native sandbox/approval defaults (no args, no managed keys written).
        let Some(permissions) = eff else {
            return PermissionProjection::empty();
        };
        let sandbox = codex_sandbox_mode(permissions);
        let approval = codex_approval_policy(permissions);

        // Permission-only TOML fragment (escaped exactly like the former
        // `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
        // permission concern and stay with the MCP wiring (LP3-3).
        let contents = format!(
            "sandbox_mode = {}\napproval_policy = {}\n",
            toml_string(sandbox),
            toml_string(approval),
        );

        let mut args = vec![
            "--sandbox".to_owned(),
            sandbox.to_owned(),
            "--ask-for-approval".to_owned(),
            approval.to_owned(),
        ];
        if sandbox == "workspace-write" && !ctx.project_root.is_empty() {
            args.push("--add-dir".to_owned());
            args.push(ctx.project_root.to_owned());
        }

        PermissionProjection {
            files: vec![ProjectedFile::MergeToml {
                rel_path: CONFIG_REL_PATH.to_owned(),
                managed_tables: Vec::new(),
                managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
                contents,
            }],
            args,
            env: Vec::new(),
        }
    }

    fn owned_replace_paths(&self) -> Vec<String> {
        // config.toml is co-owned (MergeToml), never an owned Replace file.
        Vec::new()
    }
}

fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str {
    match permissions.fallback() {
        Posture::Deny => "read-only",
        Posture::Ask | Posture::Allow => "workspace-write",
    }
}

fn codex_approval_policy(permissions: &EffectivePermissions) -> &'static str {
    match permissions.fallback() {
        Posture::Allow => "never",
        Posture::Ask | Posture::Deny => "on-request",
    }
}

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

    fn ctx<'a>() -> ProjectionContext<'a> {
        ProjectionContext {
            project_root: "/proj",
            run_dir: "/run/agent",
        }
    }

    /// Builds an [`EffectivePermissions`] with the given fallback posture via the
    /// domain API (only the fallback drives Codex's sandbox/approval derivation).
    fn eff(fallback: Posture) -> EffectivePermissions {
        resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap()
    }

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

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

    #[test]
    fn owned_replace_paths_is_empty() {
        // config.toml is co-owned (MergeToml) ⇒ nothing to clean up on swap.
        assert!(CodexPermissionProjector.owned_replace_paths().is_empty());
    }

    // ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
    //          coherence + add-dir + MergeToml shape ---------------------

    #[test]
    fn posture_maps_sandbox_and_approval_in_file_and_args() {
        for (posture, sandbox, approval) in [
            (Posture::Deny, "read-only", "on-request"),
            (Posture::Ask, "workspace-write", "on-request"),
            (Posture::Allow, "workspace-write", "never"),
        ] {
            let proj = CodexPermissionProjector.project(Some(&eff(posture)), &ctx());
            assert!(proj.env.is_empty(), "Codex projection carries no env");

            // -- The single MergeToml file, with the two managed permission keys.
            assert_eq!(proj.files.len(), 1, "exactly one file projected");
            match &proj.files[0] {
                ProjectedFile::MergeToml {
                    rel_path,
                    managed_tables,
                    managed_keys,
                    contents,
                } => {
                    assert_eq!(rel_path, CONFIG_REL_PATH);
                    assert!(managed_tables.is_empty(), "no managed tables");
                    assert_eq!(
                        managed_keys,
                        &vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
                    );
                    assert!(
                        contents.contains(&format!("sandbox_mode = \"{sandbox}\"")),
                        "posture {posture:?}: contents={contents:?}"
                    );
                    assert!(
                        contents.contains(&format!("approval_policy = \"{approval}\"")),
                        "posture {posture:?}: contents={contents:?}"
                    );
                }
                ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
            }

            // -- (7) Args reflect the SAME values as the TOML, in CLI order.
            assert_eq!(
                &proj.args[..4],
                vec![
                    "--sandbox".to_owned(),
                    sandbox.to_owned(),
                    "--ask-for-approval".to_owned(),
                    approval.to_owned(),
                ],
                "posture {posture:?}: args must mirror the TOML values"
            );
            if sandbox == "workspace-write" {
                assert!(
                    proj.args
                        .windows(2)
                        .any(|w| w == ["--add-dir".to_owned(), "/proj".to_owned()]),
                    "workspace-write posture must add the project root as writable: {:?}",
                    proj.args
                );
            } else {
                assert!(
                    !proj.args.contains(&"--add-dir".to_owned()),
                    "read-only posture must not add writable dirs: {:?}",
                    proj.args
                );
            }
        }
    }
}
