use std::{
    collections::{BTreeMap, BTreeSet},
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::{approvals::ApprovalScope, policy::ApprovalMode, vault};

pub const PROJECT_CONFIG_FILE: &str = ".ward.json";
pub const WARD_JSON_GITIGNORE_ENTRY: &str = ".ward.json";
pub const DEFAULT_VAULT_FILE: &str = ".env.vault";
pub const AGENT_INSTRUCTIONS_FILE: &str = "AGENTS.md";
pub const CLAUDE_INSTRUCTIONS_FILE: &str = "CLAUDE.md";

const ENV_EXAMPLE_HEADER: &str = "# Ward managed environment.\n# Plaintext .env files should not be committed or shared with AI agents.\n# Agents should request scoped access with ward request, then run approved commands with ward run.\n\n";
const AGENT_INSTRUCTIONS_MARKER: &str = "<!-- ward-agent-instructions -->";

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum StorageMode {
    #[default]
    VaultFile,
    Keychain,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectConfig {
    pub version: u32,
    pub project: String,
    pub vault: PathBuf,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub presets: Vec<PresetConfig>,
    #[serde(default)]
    pub profiles: BTreeMap<String, ProfileConfig>,
    #[serde(default = "default_anomaly_detection")]
    pub anomaly_detection: AnomalyDetectionConfig,
    #[serde(default)]
    pub storage_mode: StorageMode,
    /// Random hex nonce used to derive the vault filename. Regenerated on each rotation.
    #[serde(default)]
    pub vault_nonce: String,
    /// Whether the user has exported a recovery backup.
    #[serde(default)]
    pub backup_exported: bool,
    /// Whether a recovery key has been created for this project.
    #[serde(default)]
    pub recovery_created: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PresetConfig {
    pub name: String,
    #[serde(rename = "match")]
    pub match_commands: Vec<String>,
    pub allowed_env: Vec<String>,
    pub approval: ApprovalMode,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileConfig {
    pub command: String,
    pub env: Vec<String>,
    pub default_scope: ApprovalScope,
    pub action: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AnomalyDetectionConfig {
    pub enabled: bool,
    pub working_hours_start: u8,
    pub working_hours_end: u8,
    pub max_runs_per_hour_per_grant: usize,
    pub max_branches_per_grant: usize,
}

impl ProjectConfig {
    pub fn default_for_dir(cwd: &Path, project: Option<String>) -> Result<Self> {
        let project = match project {
            Some(project) => project,
            None => cwd
                .file_name()
                .and_then(|name| name.to_str())
                .map(str::to_owned)
                .context("could not infer project name from current directory")?,
        };

        let profiles = default_profiles(&default_env_keys(), cwd);

        Ok(Self {
            version: 1,
            project,
            vault: PathBuf::from(DEFAULT_VAULT_FILE),
            presets: Vec::new(),
            profiles,
            anomaly_detection: default_anomaly_detection(),
            storage_mode: StorageMode::VaultFile,
            vault_nonce: vault::generate_vault_nonce(),
            backup_exported: false,
            recovery_created: false,
        })
    }
}

pub fn config_path(cwd: &Path) -> PathBuf {
    cwd.join(PROJECT_CONFIG_FILE)
}

pub fn find_project_root(cwd: &Path) -> Option<PathBuf> {
    cwd.ancestors()
        .find(|dir| config_path(dir).is_file())
        .map(Path::to_path_buf)
}

pub fn read_project_config(cwd: &Path) -> Result<ProjectConfig> {
    let path = config_path(cwd);
    let contents =
        fs::read_to_string(&path).context(format!("failed to read {}", path.display()))?;
    let mut config: ProjectConfig =
        serde_json::from_str(&contents).context(format!("failed to parse {}", path.display()))?;
    // Backward compat: populate nonce for legacy configs that have none.
    if config.vault_nonce.is_empty() {
        config.vault_nonce = vault::generate_vault_nonce();
    }
    Ok(config)
}

pub fn write_project_config(cwd: &Path, config: &ProjectConfig, force: bool) -> Result<PathBuf> {
    let path = config_path(cwd);
    if path.exists() && !force {
        anyhow::bail!(
            "{} already exists; pass --force to overwrite",
            path.display()
        );
    }

    let contents = serde_json::to_string_pretty(config)?;
    fs::write(&path, format!("{contents}\n"))
        .context(format!("failed to write {}", path.display()))?;
    Ok(path)
}

pub fn env_keys_from_dotenv_file(path: &Path) -> Result<Vec<String>> {
    let iter =
        dotenvy::from_path_iter(path).context(format!("failed to parse {}", path.display()))?;
    env_keys_from_dotenv_iter(iter, &path.display().to_string())
}

pub fn env_keys_from_dotenv_str(contents: &str) -> Result<Vec<String>> {
    let iter = dotenvy::from_read_iter(std::io::Cursor::new(contents.as_bytes()));
    env_keys_from_dotenv_iter(iter, "dotenv contents")
}

fn env_keys_from_dotenv_iter<I, E>(iter: I, label: &str) -> Result<Vec<String>>
where
    I: IntoIterator<Item = Result<(String, String), E>>,
    E: std::error::Error + Send + Sync + 'static,
{
    let mut keys = BTreeSet::new();
    for item in iter {
        let (key, _) = item.context(format!("failed to parse {label}"))?;
        keys.insert(key);
    }
    Ok(keys.into_iter().collect())
}

pub fn default_profiles(env_keys: &[String], cwd: &Path) -> BTreeMap<String, ProfileConfig> {
    let commands = detected_commands(cwd);
    let env = env_keys.iter().cloned().collect::<BTreeSet<_>>();
    let known = |name: &str| env.contains(name);

    let mut dev_env = Vec::new();
    for name in ["DATABASE_URL", "DATABASE_URI", "PAYLOAD_SECRET"] {
        if known(name) {
            dev_env.push(name.to_string());
        }
    }
    dev_env.extend(
        env.iter()
            .filter(|name| name.starts_with("NEXT_PUBLIC_"))
            .cloned(),
    );
    let mut migrate_env = Vec::new();
    for name in ["DATABASE_URL", "DATABASE_URI", "PAYLOAD_SECRET"] {
        if known(name) {
            migrate_env.push(name.to_string());
        }
    }

    BTreeMap::from([
        (
            "dev".to_string(),
            ProfileConfig {
                command: commands.dev,
                env: dev_env,
                default_scope: ApprovalScope::Always,
                action: "Run development server".to_string(),
            },
        ),
        (
            "migrate".to_string(),
            ProfileConfig {
                command: commands.migrate,
                env: migrate_env,
                default_scope: ApprovalScope::Branch,
                action: "Run Payload migration".to_string(),
            },
        ),
    ])
}

pub fn merge_default_profiles(config: &mut ProjectConfig, env_keys: &[String], cwd: &Path) {
    for (name, profile) in default_profiles(env_keys, cwd) {
        config.profiles.entry(name).or_insert(profile);
    }
}

pub fn replace_default_profiles(config: &mut ProjectConfig, env_keys: &[String], cwd: &Path) {
    for (name, profile) in default_profiles(env_keys, cwd) {
        config.profiles.insert(name, profile);
    }
}

pub fn ensure_gitignore(cwd: &Path, commit_vault: bool) -> Result<PathBuf> {
    let path = cwd.join(".gitignore");
    let existing = if path.exists() {
        fs::read_to_string(&path).context(format!("failed to read {}", path.display()))?
    } else {
        String::new()
    };

    let vault_exception = format!("!{DEFAULT_VAULT_FILE}");
    let mut lines = existing
        .lines()
        .filter(|line| commit_vault || line.trim() != vault_exception)
        .map(str::to_string)
        .collect::<Vec<_>>();

    append_gitignore_line(&mut lines, ".env");
    append_gitignore_line(&mut lines, ".env.*");
    append_gitignore_line(&mut lines, WARD_JSON_GITIGNORE_ENTRY);
    if commit_vault {
        append_gitignore_line(&mut lines, &format!("!{DEFAULT_VAULT_FILE}"));
    }

    let mut contents = lines.join("\n");
    if !contents.is_empty() {
        contents.push('\n');
    }
    fs::write(&path, contents).context(format!("failed to write {}", path.display()))?;
    Ok(path)
}

pub fn ensure_env_example(cwd: &Path) -> Result<Option<PathBuf>> {
    let path = cwd.join(".env.example");
    if path.exists() {
        let contents =
            fs::read_to_string(&path).context(format!("failed to read {}", path.display()))?;
        if contents.contains("Ward managed environment") {
            return Ok(None);
        }

        fs::write(&path, format!("{ENV_EXAMPLE_HEADER}{contents}"))
            .context(format!("failed to write {}", path.display()))?;
        return Ok(Some(path));
    }

    fs::write(
        &path,
        format!("{ENV_EXAMPLE_HEADER}# Add non-secret env names here.\n"),
    )
    .context(format!("failed to write {}", path.display()))?;
    Ok(Some(path))
}

pub fn ensure_agent_instructions(cwd: &Path, project: &str) -> Result<Option<PathBuf>> {
    let claude_path = cwd.join(CLAUDE_INSTRUCTIONS_FILE);
    let path = if claude_path.exists() {
        claude_path
    } else {
        cwd.join(AGENT_INSTRUCTIONS_FILE)
    };
    let section = agent_instructions_section(project);

    if path.exists() {
        let contents =
            fs::read_to_string(&path).context(format!("failed to read {}", path.display()))?;
        if contents.contains(AGENT_INSTRUCTIONS_MARKER) {
            return Ok(None);
        }

        let separator = if contents.ends_with('\n') {
            "\n"
        } else {
            "\n\n"
        };
        fs::write(&path, format!("{contents}{separator}{section}"))
            .context(format!("failed to write {}", path.display()))?;
        return Ok(Some(path));
    }

    fs::write(&path, section).context(format!("failed to write {}", path.display()))?;
    Ok(Some(path))
}

fn agent_instructions_section(project: &str) -> String {
    format!(
        r#"{AGENT_INSTRUCTIONS_MARKER}
# Ward Secret Access

This repository uses Ward for local secret access. Do not read, print, copy,
or modify plaintext `.env` files. Request only the env vars needed for the
declared command.

Project: {project}

Use profiles where available:

```bash
ward request --profile dev --agent <agent-name> --worktree <absolute-path> --git-remote <remote-url-or-empty> --commit <sha> --branch <branch> --json --no-prompt
ward run --profile dev --agent <agent-name> --worktree <absolute-path> --git-remote <remote-url-or-empty> --commit <sha> --branch <branch> --json --no-prompt
ward dev --agent <agent-name> --worktree <absolute-path> --git-remote <remote-url-or-empty> --commit <sha> --branch <branch> --json --no-prompt
ward migrate --agent <agent-name> --worktree <absolute-path> --git-remote <remote-url-or-empty> --commit <sha> --branch <branch> --json --no-prompt
```

Profiles are the user-facing command layer. They map a short name such as
`dev` or `migrate` to one command and exact env names. Presets may be added to
`.ward.json` as lower-level policy rules for raw command matching and
approval behavior; prefer profiles unless a profile does not exist.

No-prompt agent calls must always send full context up front: `--agent`,
`--worktree`, `--branch`, `--git-remote`, `--commit`, `--action`, and either
`--profile` or an exact `--command` plus exact `--env` names. Do not wait for
Ward to ask follow-up questions. Ward verifies the claimed branch, remote,
commit, and worktree path locally before creating or reusing approvals.
For repositories with no `origin` remote, pass `--git-remote ""` explicitly.

Manual request template:

```bash
ward request \
  --agent <agent-name> \
  --worktree <absolute-path> \
  --branch <branch-name> \
  --git-remote <remote-url-or-empty> \
  --commit <sha> \
  --action "<why this command needs secrets>" \
  --command "<exact command to run>" \
  --env <ENV_NAME> \
  --json \
  --no-prompt
```

If a no-prompt command returns `"approvalRequired": true`, show
`approvalOptions`, `approveCommands`, `denyCommand`, and all `findings` to the
user as explicit choices. Use native structured choice UI when your agent
interface supports it; do not present approval choices as loose prose when
buttons, selectors, or typed choice prompts are available. If your structured
choice UI has a 4-option limit, present the approval scopes in the picker and
show `denyCommand` as a separate explicit denial action.

Surface `action.*` findings before asking for approval. They mean the declared
action text may include prompt-injection, approval-coercion, or secret-exposure
language.

After the user approves in the agent UI, record that approval with the matching
approve command:

```bash
ward unlock --ttl 8h
ward approve <request-id> --scope <session|branch|always> --agent-mediated --json
```

Approvals are signed. If `ward approve` or `ward allow` reports
`"status": "unlock_required"` or `signing_key_unavailable`, ask the user to run
`ward unlock --ttl 8h` and then retry the approval. Never ask the user for
the PIN/passphrase directly.

If a no-prompt command returns `"unlockRequired": true`, ask the user to run:

```bash
ward unlock --ttl 8h
```

This usually means the init/setup-created unlock expired, setup was run with
`--no-unlock`, or the user explicitly ran `ward lock`.

If a no-prompt command returns `"status": "vault_key_missing"`, do not ask the
user to unlock again. The broker is already reachable, but the approved profile
or command requested an env var that is not present in `.env.vault`. Surface
`missingEnv` and ask the user to update `.ward.json` or run `ward env
unlock`, add the missing key, then run `ward env lock`.

If the JSON response contains `"confirmationRequired": true`, show the
`confirmation.title`, `confirmation.body`, and recommended action to the user.
Do not rewrite, summarize away, or hide the critical confirmation text. Do not
auto-approve it and do not create a durable grant. Critical requests can only be
denied or approved once:

```bash
ward deny <request-id> --agent-mediated --json
ward approve <request-id> --scope once --confirm-critical --agent-mediated --json
```

Run template:

```bash
ward run \
  --agent <agent-name> \
  --worktree <absolute-path> \
  --branch <branch-name> \
  --git-remote <remote-url-or-empty> \
  --commit <sha> \
  --action "<why this command needs secrets>" \
  --env <ENV_NAME> \
  --json \
  --no-prompt \
  -- <command> <args>
```

All Ward flags must appear before `--`. Everything after `--` is the child
command and its arguments, so do not put `--json`, `--no-prompt`, `--agent`, or
other Ward flags after `--`.

Ward is passive: commands that need secrets must be run through
`ward run`. Automatic worktree delivery means Ward injects scoped
environment variables into the approved child process. It does not write
plaintext `.env` files for agents.

Never ask for, echo, store, or pipe the Ward vault PIN/passphrase.
`ward init` and `ward setup` create the initial run unlock by default; the
user may run `ward unlock --ttl 8h` locally later to refresh it. Viewing
decrypted logs always requires the user's PIN/passphrase. Agent-mediated
approvals are logged trust events, not cryptographic proof of human approval.
"#
    )
}

pub fn resolve_vault_path(cwd: &Path, config: &ProjectConfig) -> PathBuf {
    if config.vault.is_absolute() {
        config.vault.clone()
    } else {
        cwd.join(&config.vault)
    }
}

/// Derives the vault path from passphrase + project + nonce when dynamic naming is active.
/// Falls back to the static path for keychain mode or legacy configs.
pub fn resolve_vault_path_dynamic(cwd: &Path, config: &ProjectConfig, passphrase: &str) -> PathBuf {
    if config.storage_mode == StorageMode::Keychain || config.vault_nonce.is_empty() {
        return resolve_vault_path(cwd, config);
    }
    let filename = vault::derive_vault_filename(passphrase, &config.project, &config.vault_nonce);
    cwd.join(filename)
}

/// Resolves the current vault path when a passphrase is available.
///
/// Legacy projects may still have a static `.env.vault` even after a nonce was
/// added to `.ward.json`. Prefer the derived path only once it exists, or when
/// the configured static path is absent.
pub fn resolve_vault_path_with_passphrase(
    cwd: &Path,
    config: &ProjectConfig,
    passphrase: &str,
) -> PathBuf {
    let configured = resolve_vault_path(cwd, config);
    let derived = resolve_vault_path_dynamic(cwd, config, passphrase);
    if derived.exists() || !configured.exists() {
        derived
    } else {
        configured
    }
}

fn default_anomaly_detection() -> AnomalyDetectionConfig {
    AnomalyDetectionConfig {
        enabled: true,
        working_hours_start: 8,
        working_hours_end: 20,
        max_runs_per_hour_per_grant: 20,
        max_branches_per_grant: 3,
    }
}

fn default_env_keys() -> Vec<String> {
    vec![
        "DATABASE_URL".to_string(),
        "DATABASE_URI".to_string(),
        "PAYLOAD_SECRET".to_string(),
    ]
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct DetectedCommands {
    dev: String,
    migrate: String,
}

fn detected_commands(cwd: &Path) -> DetectedCommands {
    match detected_package_manager(cwd).as_deref() {
        Some("npm") => DetectedCommands {
            dev: "npm run dev".to_string(),
            migrate: "npm run payload -- migrate".to_string(),
        },
        Some("yarn") => DetectedCommands {
            dev: "yarn dev".to_string(),
            migrate: "yarn payload migrate".to_string(),
        },
        Some("bun") => DetectedCommands {
            dev: "bun run dev".to_string(),
            migrate: "bun run payload migrate".to_string(),
        },
        _ => DetectedCommands {
            dev: "pnpm dev".to_string(),
            migrate: "pnpm payload migrate".to_string(),
        },
    }
}

fn detected_package_manager(cwd: &Path) -> Option<String> {
    if let Some(manager) = package_manager_from_package_json(cwd) {
        return Some(manager);
    }
    for (file, manager) in [
        ("pnpm-lock.yaml", "pnpm"),
        ("yarn.lock", "yarn"),
        ("package-lock.json", "npm"),
        ("bun.lockb", "bun"),
        ("bun.lock", "bun"),
    ] {
        if cwd.join(file).exists() {
            return Some(manager.to_string());
        }
    }
    None
}

fn package_manager_from_package_json(cwd: &Path) -> Option<String> {
    let path = cwd.join("package.json");
    let contents = fs::read_to_string(path).ok()?;
    let value = serde_json::from_str::<serde_json::Value>(&contents).ok()?;
    let manager = value.get("packageManager")?.as_str()?;
    ["pnpm", "npm", "yarn", "bun"]
        .iter()
        .find(|candidate| manager.starts_with(&format!("{candidate}@")))
        .map(|candidate| (*candidate).to_string())
}

fn append_gitignore_line(lines: &mut Vec<String>, expected: &str) {
    if !lines.iter().any(|line| {
        let trimmed = line.trim();
        !trimmed.starts_with('#') && trimmed == expected
    }) {
        lines.push(expected.to_string());
    }
}

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

    #[test]
    fn default_config_infers_project_and_profiles() {
        let tempdir = tempfile::tempdir().unwrap();
        let config = ProjectConfig::default_for_dir(tempdir.path(), None).unwrap();

        assert_eq!(
            config.project,
            tempdir.path().file_name().unwrap().to_string_lossy()
        );
        assert_eq!(config.vault, PathBuf::from(DEFAULT_VAULT_FILE));
        assert!(config.presets.is_empty());
        let serialized = serde_json::to_string(&config).unwrap();
        assert!(!serialized.contains("\"presets\""));
        assert!(config.profiles.contains_key("dev"));
        assert!(config.anomaly_detection.enabled);
    }

    #[test]
    fn legacy_config_with_presets_still_parses() {
        let json = r#"{
          "version": 1,
          "project": "demo",
          "vault": ".env.vault",
          "presets": [
            {
              "name": "Raw dev",
              "match": ["pnpm dev"],
              "allowedEnv": ["DATABASE_URI"],
              "approval": "prompt"
            }
          ],
          "profiles": {}
        }"#;
        let config = serde_json::from_str::<ProjectConfig>(json).unwrap();
        assert_eq!(config.presets.len(), 1);
        assert_eq!(config.presets[0].allowed_env, vec!["DATABASE_URI"]);
    }

    #[test]
    fn write_project_config_refuses_overwrite_without_force() {
        let tempdir = tempfile::tempdir().unwrap();
        let config =
            ProjectConfig::default_for_dir(tempdir.path(), Some("demo".to_string())).unwrap();

        write_project_config(tempdir.path(), &config, false).unwrap();
        assert!(write_project_config(tempdir.path(), &config, false).is_err());
        assert!(write_project_config(tempdir.path(), &config, true).is_ok());
        assert_eq!(read_project_config(tempdir.path()).unwrap().project, "demo");
    }

    #[test]
    fn ensure_env_example_is_idempotent() {
        let tempdir = tempfile::tempdir().unwrap();

        let path = ensure_env_example(tempdir.path()).unwrap().unwrap();
        assert!(std::fs::read_to_string(&path)
            .unwrap()
            .contains("Ward managed environment"));
        assert!(ensure_env_example(tempdir.path()).unwrap().is_none());
    }

    #[test]
    fn ensure_env_example_prepends_existing_file() {
        let tempdir = tempfile::tempdir().unwrap();
        let path = tempdir.path().join(".env.example");
        std::fs::write(&path, "DATABASE_URL=\n").unwrap();

        assert_eq!(
            ensure_env_example(tempdir.path()).unwrap(),
            Some(path.clone())
        );
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.starts_with("# Ward managed environment."));
        assert!(contents.contains("DATABASE_URL="));
    }

    #[test]
    fn ensure_agent_instructions_creates_appends_and_is_idempotent() {
        let tempdir = tempfile::tempdir().unwrap();
        let agents_path = tempdir.path().join(AGENT_INSTRUCTIONS_FILE);

        assert_eq!(
            ensure_agent_instructions(tempdir.path(), "demo").unwrap(),
            Some(agents_path.clone())
        );
        assert!(std::fs::read_to_string(&agents_path)
            .unwrap()
            .contains("Project: demo"));
        assert!(ensure_agent_instructions(tempdir.path(), "demo")
            .unwrap()
            .is_none());

        let tempdir = tempfile::tempdir().unwrap();
        let claude_path = tempdir.path().join(CLAUDE_INSTRUCTIONS_FILE);
        std::fs::write(&claude_path, "# Existing instructions\n").unwrap();

        assert_eq!(
            ensure_agent_instructions(tempdir.path(), "claude-demo").unwrap(),
            Some(claude_path.clone())
        );
        let contents = std::fs::read_to_string(&claude_path).unwrap();
        assert!(contents.contains("# Existing instructions"));
        assert!(contents.contains("Project: claude-demo"));
        assert!(contents.contains("Profiles are the user-facing command layer"));
        assert!(contents.contains("Presets may be added"));
        assert!(contents.contains("All Ward flags must appear before `--`"));

        let tempdir = tempfile::tempdir().unwrap();
        let claude_path = tempdir.path().join(CLAUDE_INSTRUCTIONS_FILE);
        std::fs::write(&claude_path, "# Existing instructions").unwrap();

        assert_eq!(
            ensure_agent_instructions(tempdir.path(), "no-newline-demo").unwrap(),
            Some(claude_path.clone())
        );
        let contents = std::fs::read_to_string(&claude_path).unwrap();
        assert!(contents.contains("# Existing instructions\n\n<!-- ward-agent-instructions -->"));
    }

    #[test]
    fn resolves_absolute_and_relative_vault_paths() {
        let tempdir = tempfile::tempdir().unwrap();
        let mut config =
            ProjectConfig::default_for_dir(tempdir.path(), Some("demo".to_string())).unwrap();

        assert_eq!(
            resolve_vault_path(tempdir.path(), &config),
            tempdir.path().join(DEFAULT_VAULT_FILE)
        );

        config.vault = tempdir.path().join("custom.vault");
        assert_eq!(resolve_vault_path(tempdir.path(), &config), config.vault);
    }

    #[test]
    fn dotenv_keys_and_default_profiles_use_exact_env_names() {
        let tempdir = tempfile::tempdir().unwrap();
        let env_path = tempdir.path().join(".env");
        std::fs::write(
            &env_path,
            "DATABASE_URL=postgres://local\nPAYLOAD_SECRET=payload\nNEXT_PUBLIC_API_URL=http://localhost\nOPENAI_API_KEY=test\n",
        )
        .unwrap();

        let keys = env_keys_from_dotenv_file(&env_path).unwrap();
        let profiles = default_profiles(&keys, tempdir.path());

        assert_eq!(
            profiles["dev"].env,
            vec![
                "DATABASE_URL".to_string(),
                "PAYLOAD_SECRET".to_string(),
                "NEXT_PUBLIC_API_URL".to_string(),
            ]
        );
        assert_eq!(
            profiles["migrate"].env,
            vec!["DATABASE_URL".to_string(), "PAYLOAD_SECRET".to_string()]
        );
        assert!(!profiles["dev"].env.iter().any(|name| name.contains('*')));

        let keys = env_keys_from_dotenv_str("DATABASE_URI=mongodb://local\n").unwrap();
        let profiles = default_profiles(&keys, tempdir.path());
        assert_eq!(profiles["dev"].env, vec!["DATABASE_URI".to_string()]);
        assert_eq!(profiles["migrate"].env, vec!["DATABASE_URI".to_string()]);
        assert!(!profiles["dev"].env.contains(&"DATABASE_URL".to_string()));
    }

    #[test]
    fn profile_generation_and_detection_use_package_metadata_and_lockfiles() {
        let tempdir = tempfile::tempdir().unwrap();
        std::fs::write(
            tempdir.path().join("package.json"),
            r#"{"packageManager":"npm@10.0.0"}"#,
        )
        .unwrap();
        assert_eq!(
            package_manager_from_package_json(tempdir.path()),
            Some("npm".to_string())
        );
        assert_eq!(detected_commands(tempdir.path()).dev, "npm run dev");

        std::fs::write(tempdir.path().join("package.json"), "{bad-json}").unwrap();
        std::fs::write(tempdir.path().join("yarn.lock"), "").unwrap();
        assert_eq!(
            detected_package_manager(tempdir.path()),
            Some("yarn".to_string())
        );
        assert_eq!(
            detected_commands(tempdir.path()).migrate,
            "yarn payload migrate"
        );

        let bun = tempfile::tempdir().unwrap();
        std::fs::write(bun.path().join("bun.lock"), "").unwrap();
        assert_eq!(detected_commands(bun.path()).dev, "bun run dev");

        let fallback = tempfile::tempdir().unwrap();
        assert_eq!(detected_commands(fallback.path()).dev, "pnpm dev");
    }

    #[test]
    fn merge_profiles_and_gitignore_updates_are_idempotent() {
        let tempdir = tempfile::tempdir().unwrap();
        let mut config =
            ProjectConfig::default_for_dir(tempdir.path(), Some("demo".to_string())).unwrap();
        config.profiles.clear();

        merge_default_profiles(
            &mut config,
            &["DATABASE_URL".to_string(), "PAYLOAD_SECRET".to_string()],
            tempdir.path(),
        );
        assert!(config.profiles.contains_key("dev"));
        let original = config.profiles["dev"].clone();
        config.profiles.get_mut("dev").unwrap().command = "custom dev".to_string();
        merge_default_profiles(&mut config, &[], tempdir.path());
        assert_eq!(config.profiles["dev"].command, "custom dev");
        assert_ne!(config.profiles["dev"], original);

        let empty = tempfile::tempdir().unwrap();
        ensure_gitignore(empty.path(), true).unwrap();
        let contents = std::fs::read_to_string(empty.path().join(".gitignore")).unwrap();
        assert!(contents.contains(".env\n"));
        assert!(contents.contains(".env.*\n"));
        assert!(contents.contains("!.env.vault\n"));

        std::fs::write(tempdir.path().join(".gitignore"), "# existing\n.env\n").unwrap();
        ensure_gitignore(tempdir.path(), true).unwrap();
        let contents = std::fs::read_to_string(tempdir.path().join(".gitignore")).unwrap();
        assert!(contents.contains(".env\n"));
        assert!(contents.contains(".env.*\n"));
        assert!(contents.contains("!.env.vault\n"));

        ensure_gitignore(tempdir.path(), false).unwrap();
        let contents = std::fs::read_to_string(tempdir.path().join(".gitignore")).unwrap();
        assert!(!contents.contains("!.env.vault"));
    }

    #[test]
    fn env_key_parsing_reports_invalid_dotenv() {
        let tempdir = tempfile::tempdir().unwrap();
        let env_path = tempdir.path().join(".env");
        std::fs::write(&env_path, "DATABASE_URL='unterminated\n").unwrap();

        assert!(env_keys_from_dotenv_file(&env_path).is_err());
    }
}
