//! Codex CLI permission projector (lot LP3-2).
//!
//! Produces the **permission-relevant** part of Codex's `config.toml`
//! (`sandbox_mode` / `approval_policy` / `sandbox_workspace_write.network_access`) 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 permission keys/table 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 domain::profile::{CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML};
use domain::NetworkPolicy;

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 top-level keys this projector manages in `config.toml`. Everything else
/// (MCP table, trust entries, user keys) is preserved by the merge.
const PERMISSION_MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];

/// Codex workspace-write sandbox table owned by IdeA for network projection.
const SANDBOX_WORKSPACE_WRITE_TABLE: &str = "sandbox_workspace_write";

/// 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>,
        network: Option<NetworkPolicy>,
        ctx: &ProjectionContext,
    ) -> PermissionProjection {
        // No filesystem/bash policy ⇒ no sandbox/approval projection. Network is
        // orthogonal and still gets an explicit env override to avoid stale inheritance.
        let Some(permissions) = eff else {
            return PermissionProjection {
                files: vec![codex_network_file(network)],
                env: codex_network_env(network),
                ..PermissionProjection::empty()
            };
        };
        let sandbox = codex_sandbox_mode(permissions);
        let approval = codex_approval_policy(permissions);
        let network_access = codex_network_access(network);

        let mut contents = format!(
            "sandbox_mode = {}\napproval_policy = {}\n",
            toml_string(sandbox),
            toml_string(approval),
        );
        contents.push_str(&format!(
            "\n[{}]\nnetwork_access = {}\n\n{}",
            SANDBOX_WORKSPACE_WRITE_TABLE, network_access, CODEX_CODE_MODE_FEATURES_TOML,
        ));

        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: codex_managed_tables(),
                managed_keys: codex_managed_keys(true),
                contents,
            }],
            args,
            env: codex_network_env(network),
        }
    }

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

fn codex_network_file(network: Option<NetworkPolicy>) -> ProjectedFile {
    let contents = format!(
        "[{}]\nnetwork_access = {}\n\n{}",
        SANDBOX_WORKSPACE_WRITE_TABLE,
        codex_network_access(network),
        CODEX_CODE_MODE_FEATURES_TOML,
    );
    ProjectedFile::MergeToml {
        rel_path: CONFIG_REL_PATH.to_owned(),
        managed_tables: codex_managed_tables(),
        managed_keys: codex_managed_keys(false),
        contents,
    }
}

fn codex_managed_keys(include_permissions: bool) -> Vec<String> {
    let mut keys = Vec::new();
    if include_permissions {
        keys.extend(PERMISSION_MANAGED_KEYS.iter().map(|k| (*k).to_owned()));
    }
    keys
}

fn codex_managed_tables() -> Vec<String> {
    vec![
        SANDBOX_WORKSPACE_WRITE_TABLE.to_owned(),
        CODEX_CODE_MODE_FEATURES_TABLE.to_owned(),
    ]
}

fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> {
    // Codex inherits the parent environment by default. Always set the variable for
    // Codex launches so a stale `CODEX_SANDBOX_NETWORK_DISABLED=1` in IdeA's own
    // environment cannot leak into a newly allowed child.
    let disabled = if codex_network_access(network) {
        "0"
    } else {
        "1"
    };
    vec![(
        "CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
        disabled.to_owned(),
    )]
}

fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
    matches!(network, Some(NetworkPolicy::Allow))
}

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",
            model: None,
        }
    }

    /// 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()
    }

    fn expected_managed_tables() -> Vec<String> {
        vec![
            SANDBOX_WORKSPACE_WRITE_TABLE.to_owned(),
            CODEX_CODE_MODE_FEATURES_TABLE.to_owned(),
        ]
    }

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

    #[test]
    fn project_none_is_empty() {
        let proj = CodexPermissionProjector.project(None, None, &ctx());
        assert_eq!(proj.files.len(), 1);
        assert!(proj.args.is_empty());
        match &proj.files[0] {
            ProjectedFile::MergeToml {
                rel_path,
                managed_tables,
                managed_keys,
                contents,
            } => {
                assert_eq!(rel_path, CONFIG_REL_PATH);
                assert_eq!(managed_tables, &expected_managed_tables());
                assert!(managed_keys.is_empty());
                assert!(
                    contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
                    "network is denied by default: {contents:?}"
                );
                assert!(
                    contents.contains(
                        "[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"
                    ),
                    "Code Mode direct tools must be restricted to IdeA MCP: {contents:?}"
                );
            }
            ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
        }
        assert_eq!(
            proj.env,
            vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())]
        );
    }

    #[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());
    }

    #[test]
    fn projection_without_permissions_writes_network_only_and_leaves_model_to_exec_args() {
        let ctx = ProjectionContext {
            project_root: "/proj",
            run_dir: "/run/agent",
            model: Some("gpt-5"),
        };
        let proj = CodexPermissionProjector.project(None, None, &ctx);
        assert!(proj.args.is_empty());
        match &proj.files[0] {
            ProjectedFile::MergeToml {
                managed_tables,
                managed_keys,
                contents,
                ..
            } => {
                assert!(!managed_keys.contains(&"model".to_owned()));
                assert!(!managed_keys.contains(&"model_provider".to_owned()));
                assert_eq!(managed_tables, &expected_managed_tables());
                assert!(!contents.contains("model ="), "{contents}");
                assert!(contents.contains("[sandbox_workspace_write]"), "{contents}");
                assert!(!contents.contains("model_provider"), "{contents}");
                assert!(!contents.contains("model_providers"), "{contents}");
                assert!(!contents.contains("base_url"), "{contents}");
                assert!(!contents.contains("env_key"), "{contents}");
            }
            ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
        }
    }

    // ---- (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)), None, &ctx());
            assert_eq!(
                proj.env,
                vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
                "Codex projection denies network by default"
            );

            // -- 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_eq!(managed_tables, &expected_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:?}"
                    );
                    assert!(
                        contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
                        "network is denied by default: {contents:?}"
                    );
                    assert!(
                        contents.contains(
                            "[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"
                        ),
                        "Code Mode direct tools must be restricted to IdeA MCP: {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
                );
            }
        }
    }

    #[test]
    fn network_policy_maps_to_stale_proof_env() {
        for (network, disabled, network_access) in [
            (Some(NetworkPolicy::Allow), "0", true),
            (Some(NetworkPolicy::Deny), "1", false),
            (Some(NetworkPolicy::Ask), "1", false),
            (None, "1", false),
        ] {
            let proj =
                CodexPermissionProjector.project(Some(&eff(Posture::Allow)), network, &ctx());
            assert_eq!(
                proj.env,
                vec![(
                    "CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
                    disabled.to_owned()
                )],
                "network={network:?}"
            );
            match &proj.files[0] {
                ProjectedFile::MergeToml { contents, .. } => assert!(
                    contents.contains(&format!(
                        "[sandbox_workspace_write]\nnetwork_access = {network_access}"
                    )),
                    "network={network:?}: {contents:?}"
                ),
                ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
            }
        }
    }
}
