use std::fs;
use std::io::{self, ErrorKind, IsTerminal, Write as IoWrite};
use std::path::{Path, PathBuf};

// ── Canonical sources (single source of truth) ──────────────────────────────

static SKILL_COMMIT: &str = include_str!("../registry/skills/commit/SKILL.md");
static SKILL_CONTEXT: &str = include_str!("../registry/skills/context/SKILL.md");
static SKILL_DEBUG: &str = include_str!("../registry/skills/debug/SKILL.md");
static SKILL_DOCUMENT: &str = include_str!("../registry/skills/document/SKILL.md");
static SKILL_PERF: &str = include_str!("../registry/skills/perf/SKILL.md");
static SKILL_SECURE: &str = include_str!("../registry/skills/secure/SKILL.md");
static SKILL_SIMPLIFY: &str = include_str!("../registry/skills/simplify/SKILL.md");
static SKILL_TDD: &str = include_str!("../registry/skills/tdd/SKILL.md");
static SKILL_VERIFY: &str = include_str!("../registry/skills/verify/SKILL.md");
static SKILL_COUNCIL: &str = include_str!("../registry/skills/council/SKILL.md");
static SKILL_AGENT_INTROSPECTION: &str =
    include_str!("../registry/skills/agent-introspection/SKILL.md");
static SKILL_REFLECT: &str = include_str!("../registry/skills/reflect/SKILL.md");
static SKILL_DISCOVER: &str = include_str!("../registry/skills/discover/SKILL.md");
static SKILL_ORCHESTRATE: &str = include_str!("../registry/skills/orchestrate/SKILL.md");
static SKILL_SPEC: &str = include_str!("../registry/skills/spec/SKILL.md");
static SKILL_GO: &str = include_str!("../registry/skills/go/SKILL.md");
static SKILL_CHECK: &str = include_str!("../registry/skills/check/SKILL.md");
static SKILL_SHIP: &str = include_str!("../registry/skills/ship/SKILL.md");
// _dispatch is Claude Code only, not installed to other tools
static CMD_EVOLVE: &str = include_str!("../registry/commands/evolve.md");
static CMD_TEAM: &str = include_str!("../registry/commands/team.md");
static CMD_ORBIT: &str = include_str!("../registry/commands/orbit.md");

static SKILL_DISPATCH: &str = include_str!("../registry/skills/_dispatch/SKILL.md");

static CANONICAL_SKILLS: &[(&str, &str)] = &[
    ("commit", SKILL_COMMIT),
    ("context", SKILL_CONTEXT),
    ("debug", SKILL_DEBUG),
    ("document", SKILL_DOCUMENT),
    ("perf", SKILL_PERF),
    ("secure", SKILL_SECURE),
    ("simplify", SKILL_SIMPLIFY),
    ("tdd", SKILL_TDD),
    ("verify", SKILL_VERIFY),
    ("council", SKILL_COUNCIL),
    ("agent-introspection", SKILL_AGENT_INTROSPECTION),
    ("reflect", SKILL_REFLECT),
    ("discover", SKILL_DISCOVER),
    ("orchestrate", SKILL_ORCHESTRATE),
    ("spec", SKILL_SPEC),
    ("go", SKILL_GO),
    ("check", SKILL_CHECK),
    ("ship", SKILL_SHIP),
];

// ── Per-skill Memory Integration sections (appended for codex) ──────────────

static MEM_SECTION_COMMIT: &str = "";
static MEM_SECTION_CONTEXT: &str = r#"
**CRITICAL**: Run `HARNESS_DIR=$(epic path)` first. NEVER use `.harness/` in the project directory.
"#;
static MEM_SECTION_DEBUG: &str = "";
static MEM_SECTION_DOCUMENT: &str = r#"
## Memory Integration

Check existing memory before writing docs to avoid duplication:
```
epic-harness mem search "<module or function name>"
# or via MCP: mem_search(query="<module or function name>")
```
"#;
static MEM_SECTION_PERF: &str = r#"
## Memory Integration

**Before review**: Check known performance patterns.
```
epic-harness mem search "performance" --limit 5
# or via MCP: mem_search(query="performance")
```

**After review** (if a perf pattern or bottleneck found):
```
epic-harness mem add --title "<pattern>" --type pattern --tags "performance" --body "<finding and fix>"
# or via MCP: mem_add(title="...", type="pattern", tags=["performance"], body="...")
```
"#;
static MEM_SECTION_SECURE: &str = r#"
## Memory Integration

**Before review**: Check known security patterns.
```
epic-harness mem search "security" --limit 5
# or via MCP: mem_search(query="security")
```

**After review** (if a security decision was made):
```
epic-harness mem add --title "<decision>" --type decision --tags "security" --body "<rationale>"
# or via MCP: mem_add(title="...", type="decision", tags=["security"], body="...")
```
"#;
static MEM_SECTION_SIMPLIFY: &str = r#"
## Memory Integration

If a significant architectural insight emerged from simplification:
```
epic-harness mem add --title "<insight>" --type concept --tags "architecture,refactor" --body "<what was simplified and why>"
# or via MCP: mem_add(title="...", type="concept", tags=["architecture"], body="...")
```
"#;
static MEM_SECTION_TDD: &str = r#"
## Memory Integration

**Session start**: Load relevant patterns before implementing.
```
epic-harness mem search "<feature keyword>"
# or via MCP: mem_search(query="<feature keyword>")
```

**After refactor** (if a notable pattern emerged):
```
epic-harness mem add --title "<pattern name>" --type pattern --tags "<stack>,tdd" --body "<what was learned>"
# or via MCP: mem_add(title="...", type="pattern", body="...", tags=[...])
```
"#;
static MEM_SECTION_VERIFY: &str = r#"
## Memory Integration

**Before verifying**: Load project context.
```
epic-harness mem context --project <current-project>
# or via MCP: mem_context(project="<current-project>")
```

**If bugs/regressions found**: Record as error node.
```
epic-harness mem add --title "<bug description>" --type error --tags "<component>" --body "<root cause and fix>"
# or via MCP: mem_add(title="...", type="error", body="...", tags=[...])
```
"#;

fn mem_section_for_skill(name: &str) -> &'static str {
    match name {
        "commit" => MEM_SECTION_COMMIT,
        "context" => MEM_SECTION_CONTEXT,
        "debug" => MEM_SECTION_DEBUG,
        "document" => MEM_SECTION_DOCUMENT,
        "perf" => MEM_SECTION_PERF,
        "secure" => MEM_SECTION_SECURE,
        "simplify" => MEM_SECTION_SIMPLIFY,
        "tdd" => MEM_SECTION_TDD,
        "verify" => MEM_SECTION_VERIFY,
        _ => "",
    }
}

// ── Transform functions ─────────────────────────────────────────────────────

/// Strip YAML frontmatter from a markdown document, returning just the body.
fn strip_frontmatter(md: &str) -> &str {
    if let Some(rest) = md.strip_prefix("---")
        && let Some(end) = rest.find("\n---")
    {
        let after = end + 4; // skip past \n---
        // Skip the trailing newline after closing ---
        if after < rest.len() && rest.as_bytes()[after] == b'\n' {
            return &rest[after + 1..];
        }
        if after < rest.len() {
            return &rest[after..];
        }
        return "";
    }
    md
}

/// Transform a canonical skill for a specific tool.
fn transform_skill(tool: &str, name: &str, canonical: &str) -> String {
    match tool {
        "codex" => {
            let mut result = canonical.to_string();

            // For context skill: apply inline mem-integration edits
            if name == "context" {
                result = result.replace(
                    "- Key decisions made \u{2192} note in conversation",
                    "- Key decisions made \u{2192} **save to memory before compacting**:\n  ```\n  epic-harness mem add --title \"<decision>\" --type decision --tags \"<project>\" --body \"<context and rationale>\"\n  # or via MCP: mem_add(title=\"...\", type=\"decision\", body=\"...\")\n  ```",
                );
                result = result.replace(
                    "- Project memory from `$HARNESS_DIR/memory/`",
                    "- Project memory from `~/.harness/memory.db` via `resume` hook",
                );
                // Add reload context after the evolved skills line
                result = result.replace(
                    "- Evolved skills from `$HARNESS_DIR/evolved/`",
                    "- Evolved skills from `$HARNESS_DIR/evolved/`\n- Reload project context manually if needed:\n  ```\n  epic-harness mem context --project <current-project>\n  # or via MCP: mem_context(project=\"<current-project>\")\n  ```",
                );
                // Add mem evidence item
                result = result.replace(
                    "- [ ] Snapshot written to `$HARNESS_DIR/sessions/` (show file name)",
                    "- [ ] Key decisions saved to memory (show mem add output or MCP call)\n- [ ] Snapshot written to `$HARNESS_DIR/sessions/` (show file name)",
                );
            }

            // Minor text normalizations
            if name == "tdd" || name == "verify" {
                result = result.replace("subagents", "sub-agents");
                result = result.replace("subagent", "sub-agent");
            }

            // Insert CRITICAL HARNESS_DIR line for context
            if name == "context" {
                let critical_line = "\n**CRITICAL**: Run `HARNESS_DIR=$(epic-harness path)` first. NEVER use `.harness/` in the project directory.\n";
                // Insert after frontmatter closing ---
                if let Some(pos) = result[3..].find("\n---\n") {
                    let insert_at = 3 + pos + 5;
                    result.insert_str(insert_at, critical_line);
                }
            }

            // Append Memory Integration section
            let mem_section = mem_section_for_skill(name);
            if !mem_section.is_empty() && name != "context" {
                // context's mem section is handled inline above
                result = format!("{}{}", result.trim_end(), mem_section);
            }

            result
        }
        "antigravity" => {
            let mut result = canonical.to_string();

            // Text normalizations
            if name == "tdd" || name == "verify" {
                result = result.replace("subagents", "sub-agents");
                result = result.replace("subagent", "sub-agent");
            }

            // Append Memory Integration section (same as codex)
            let mem_section = mem_section_for_skill(name);
            if !mem_section.is_empty() {
                result = format!("{}{}", result.trim_end(), mem_section);
            }

            result
        }
        _ => canonical.to_string(),
    }
}

/// Build the cursor harness-skills.mdc from canonical skills.
fn build_cursor_skills_mdc() -> String {
    let mut out = String::from(
        "---\ndescription: \"epic-harness quality skills \u{2014} TDD, security, verify, simplify, perf. Apply when implementing features, touching auth/DB/API code, or before marking tasks done.\"\nalwaysApply: false\n---\n# epic-harness Quality Skills\n\nCore skill rules applied automatically throughout every session.\n",
    );
    for (name, content) in CANONICAL_SKILLS {
        // Skip debug and context for cursor (they are operational, not quality skills)
        if *name == "debug" || *name == "context" {
            continue;
        }
        let body = strip_frontmatter(content);
        out.push_str(&format!("\n---\n\n{}\n", body.trim()));
    }
    out
}

// ── Embedded integration files ────────────────────────────────────────────────

macro_rules! integration_files {
    ($tool:literal, [ $( ($rel:literal, $content:expr) ),* $(,)? ]) => {
        &[ $( ($rel, $content) ),* ]
    };
}

static HARNESS_MD: &str = include_str!("../integrations/common/HARNESS.md");

static CODEX_FILES: &[(&str, &str)] = integration_files!(
    "codex",
    [
        (
            "hooks.json",
            include_str!("../integrations/codex/hooks.json")
        ),
        // config.toml: enables codex_hooks (off by default without this).
        (
            "config.toml",
            include_str!("../integrations/codex/config.toml")
        ),
    ]
);

/// Codex command-skills: converted from deprecated prompts/ to skills/ format at install time.
/// Each entry is (skill_name, source_markdown_with_frontmatter).
static CODEX_COMMAND_SKILLS: &[(&str, &str)] = &[
    (
        "evolve",
        include_str!("../integrations/codex/prompts/evolve.md"),
    ),
    (
        "team",
        include_str!("../integrations/codex/prompts/team.md"),
    ),
    (
        "orbit",
        include_str!("../integrations/codex/prompts/orbit.md"),
    ),
];

static CURSOR_FILES: &[(&str, &str)] = integration_files!(
    "cursor",
    [
        (
            "hooks.json",
            include_str!("../integrations/cursor/hooks.json")
        ),
        (
            "rules/harness-context.mdc",
            include_str!("../integrations/cursor/rules/harness-context.mdc")
        ),
        (
            "commands/evolve.md",
            include_str!("../integrations/cursor/commands/evolve.md")
        ),
        (
            "commands/team.md",
            include_str!("../integrations/cursor/commands/team.md")
        ),
        (
            "commands/orbit.md",
            include_str!("../integrations/cursor/commands/orbit.md")
        ),
    ]
);

static OPENCODE_FILES: &[(&str, &str)] = integration_files!(
    "opencode",
    [
        (
            "commands/evolve.md",
            include_str!("../integrations/opencode/commands/evolve.md")
        ),
        (
            "commands/team.md",
            include_str!("../integrations/opencode/commands/team.md")
        ),
        (
            "commands/orbit.md",
            include_str!("../integrations/opencode/commands/orbit.md")
        ),
        (
            "plugins/epic-harness.js",
            include_str!("../integrations/opencode/plugins/epic-harness.js")
        ),
    ]
);

static CLINE_FILES: &[(&str, &str)] = integration_files!(
    "cline",
    [
        (
            "hooks/PreToolUse",
            include_str!("../integrations/cline/hooks/PreToolUse")
        ),
        (
            "hooks/PostToolUse",
            include_str!("../integrations/cline/hooks/PostToolUse")
        ),
        (
            "hooks/TaskStart",
            include_str!("../integrations/cline/hooks/TaskStart")
        ),
        (
            "hooks/TaskResume",
            include_str!("../integrations/cline/hooks/TaskResume")
        ),
        (
            "hooks/TaskCancel",
            include_str!("../integrations/cline/hooks/TaskCancel")
        ),
        (
            "rules/epic-harness.md",
            include_str!("../integrations/cline/rules/epic-harness.md")
        ),
    ]
);

static AIDER_FILES: &[(&str, &str)] = integration_files!(
    "aider",
    [
        (
            ".aider.conf.yml",
            include_str!("../integrations/aider/.aider.conf.yml")
        ),
        (
            ".aider/CONVENTIONS.md",
            include_str!("../integrations/aider/.aider/CONVENTIONS.md")
        ),
    ]
);

static CLAUDE_FILES: &[(&str, &str)] = integration_files!(
    "claude",
    [(".claude/settings.json", include_str!("../hooks/hooks.json")),]
);

// Antigravity: gemini-extension.json manifest + context + hooks.
// Installed to ~/.gemini/config/plugins/epic/ (global) or .agents/plugins/epic/ (local).
static ANTIGRAVITY_FILES: &[(&str, &str)] = integration_files!(
    "antigravity",
    [
        (
            "gemini-extension.json",
            include_str!("../integrations/antigravity/gemini-extension.json")
        ),
        (
            "GEMINI.md",
            include_str!("../integrations/antigravity/GEMINI.md")
        ),
        (
            "hooks/hooks.json",
            include_str!("../integrations/antigravity/hooks/hooks.json")
        ),
    ]
);

// ── Tool config ───────────────────────────────────────────────────────────────

struct ToolConfig {
    /// Destination directory (global default)
    global_dir: PathBuf,
    /// Destination directory override for --local
    local_dir: PathBuf,
    /// Files that live at project root (e.g. AGENTS.md)
    root_files: &'static [&'static str],
    /// Files embedded in the binary
    files: &'static [(&'static str, &'static str)],
    /// Extra note shown after install
    note: Option<&'static str>,
    /// Files that should never be overwritten if they already exist (e.g. config.toml).
    /// Unlike root_files these live inside the tool dir, not in cwd.
    preserve_files: &'static [&'static str],
    /// Files that must be made executable after writing (chmod +x on Unix).
    executable_files: &'static [&'static str],
}

fn tool_config(tool: &str) -> Option<ToolConfig> {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));

    match tool {
        "codex" => Some(ToolConfig {
            global_dir: PathBuf::from(&home).join(".codex"),
            local_dir: cwd.join(".codex"),
            root_files: &[],
            files: CODEX_FILES,
            note: None,
            // config.toml may contain user-customised settings — never overwrite.
            preserve_files: &["config.toml"],
            executable_files: &[],
        }),
        "cursor" => Some(ToolConfig {
            global_dir: PathBuf::from(&home).join(".cursor"),
            local_dir: cwd.join(".cursor"),
            root_files: &[],
            files: CURSOR_FILES,
            note: Some("Requires Cursor 1.7+"),
            preserve_files: &[],
            executable_files: &[],
        }),
        "opencode" => Some(ToolConfig {
            global_dir: PathBuf::from(&home).join(".config").join("opencode"),
            local_dir: cwd.join(".opencode"),
            root_files: &[],
            files: OPENCODE_FILES,
            note: Some("Place plugins/epic-harness.js in your OpenCode plugin directory."),
            preserve_files: &[],
            executable_files: &[],
        }),
        "cline" => Some(ToolConfig {
            global_dir: PathBuf::from(&home)
                .join("Documents")
                .join("Cline")
                .join("Rules"),
            local_dir: cwd.join(".clinerules"),
            root_files: &[],
            files: CLINE_FILES,
            note: Some(
                "Hook scripts have been made executable. \
                 For global hooks, also copy hooks/ to ~/Documents/Cline/Rules/Hooks/.",
            ),
            preserve_files: &[],
            executable_files: &[
                "hooks/PreToolUse",
                "hooks/PostToolUse",
                "hooks/TaskStart",
                "hooks/TaskResume",
                "hooks/TaskCancel",
            ],
        }),
        // Aider has no hook system. We install:
        //  - ~/.aider.conf.yml  (auto-loads conventions; preserved if already exists)
        //  - ~/.aider/CONVENTIONS.md  (coding rules injected into every session)
        // global_dir = $HOME so both paths resolve correctly.
        "aider" => Some(ToolConfig {
            global_dir: PathBuf::from(&home),
            local_dir: cwd.clone(),
            root_files: &[],
            files: AIDER_FILES,
            note: Some("No hook system available. Conventions are loaded via .aider.conf.yml."),
            preserve_files: &[".aider.conf.yml"],
            executable_files: &[],
        }),
        // Antigravity: gemini-extension bundle at ~/.gemini/config/plugins/epic/ (global)
        // or .agents/plugins/epic/ (local). Skills, commands, agents, hooks, MCP included.
        "antigravity" => Some(ToolConfig {
            global_dir: PathBuf::from(&home)
                .join(".gemini")
                .join("config")
                .join("plugins")
                .join("epic"),
            local_dir: cwd.join(".agents").join("plugins").join("epic"),
            root_files: &[],
            files: ANTIGRAVITY_FILES,
            note: Some(
                "Antigravity extension installed with skills, commands, hooks, and harness-mem MCP.",
            ),
            preserve_files: &[],
            executable_files: &[],
        }),
        // Claude Code: install hooks into ~/.claude/settings.json + MCP injection via inject_mcp_claude().
        "claude" => Some(ToolConfig {
            global_dir: PathBuf::from(&home),
            local_dir: cwd.clone(),
            root_files: &[],
            files: CLAUDE_FILES,
            note: Some(
                "Installs hooks in ~/.claude/settings.json and registers harness-mem MCP server in ~/.claude.json.",
            ),
            preserve_files: &[],
            executable_files: &[],
        }),
        _ => None,
    }
}

// ── Progress bar ──────────────────────────────────────────────────────────────

struct Progress {
    tool: String,
    total: usize,
    current: usize,
    added: usize,
    updated: usize,
    unchanged: usize,
    dry_run: bool,
    tty: bool,
}

impl Progress {
    fn new(tool: &str, total: usize, dry_run: bool) -> Self {
        Self {
            tool: tool.to_string(),
            total,
            current: 0,
            added: 0,
            updated: 0,
            unchanged: 0,
            dry_run,
            tty: io::stderr().is_terminal(),
        }
    }

    fn tick(&mut self, filename: &str, status: FileStatus) {
        self.current += 1;
        match status {
            FileStatus::Added => self.added += 1,
            FileStatus::Updated => self.updated += 1,
            FileStatus::Unchanged => self.unchanged += 1,
        }

        if self.tty {
            let filled = (self.current * 20).checked_div(self.total).unwrap_or(20);
            let bar: String = std::iter::repeat_n('=', filled.saturating_sub(1))
                .chain(if filled > 0 && filled < 20 {
                    std::iter::once('>')
                } else {
                    std::iter::once('=')
                })
                .chain(std::iter::repeat_n(' ', 20 - filled))
                .collect();

            let name = if filename.len() > 26 {
                &filename[filename.len() - 26..]
            } else {
                filename
            };

            let tag = if self.dry_run { "dry-run" } else { &self.tool };
            eprint!(
                "\r  {:<8} [{}] {:>2}/{:<2}  {:<26}",
                tag, bar, self.current, self.total, name
            );
            let _ = io::stderr().flush();
        } else {
            // Non-TTY (CI / piped): compact one-line summary per tool, not per file
        }
    }

    fn finish(&self) {
        let dry = if self.dry_run { " (dry-run)" } else { "" };
        if self.tty {
            eprint!("\r{}\r", " ".repeat(60)); // clear bar line
            eprintln!(
                "  {:<8} ✓ {} files{}  ({} added, {} updated, {} unchanged)",
                self.tool, self.total, dry, self.added, self.updated, self.unchanged
            );
        } else {
            eprintln!(
                "[harness] {}: {} files{}  ({} added, {} updated, {} unchanged)",
                self.tool, self.total, dry, self.added, self.updated, self.unchanged
            );
        }
    }
}

#[derive(Clone, Copy)]
enum FileStatus {
    Added,
    Updated,
    Unchanged,
}

// ── Install logic ─────────────────────────────────────────────────────────────

/// Root-only files (e.g. AGENTS.md): never overwrite — user may have edited or merged.
/// Returns the FileStatus so the caller can update progress.
fn write_if_missing(dest: &Path, content: &str, dry_run: bool) -> FileStatus {
    if dest.exists() {
        return FileStatus::Unchanged;
    }
    if dry_run {
        return FileStatus::Added;
    }
    if let Some(parent) = dest.parent() {
        let _ = fs::create_dir_all(parent);
    }
    match fs::write(dest, content) {
        Ok(_) => FileStatus::Added,
        Err(e) if e.kind() == ErrorKind::PermissionDenied => {
            eprintln!(
                "\n[harness] WARN: permission denied writing {} — \
                 grant Full Disk Access to your terminal in System Settings > Privacy & Security, \
                 or skip global install.",
                dest.display()
            );
            FileStatus::Unchanged
        }
        Err(e) => {
            eprintln!("\n[harness] ERROR writing {}: {e}", dest.display());
            FileStatus::Unchanged
        }
    }
}

/// Hooks, commands, agents, rules, skills, etc.: write if missing or content differs from embedded.
fn write_or_sync(dest: &Path, content: &str, dry_run: bool) -> FileStatus {
    let existed = dest.exists();
    let is_settings_json = dest.file_name().is_some_and(|n| n == "settings.json");

    if is_settings_json && existed {
        // For settings.json, merge instead of overwriting to preserve theme, auth, etc.
        let existing_content = fs::read_to_string(dest).unwrap_or_default();

        let mut existing_json: serde_json::Value =
            serde_json::from_str(&existing_content).unwrap_or(serde_json::json!({}));
        let new_json: serde_json::Value =
            serde_json::from_str(content).unwrap_or(serde_json::json!({}));

        if let Some(v) = new_json.get("hooksConfig") {
            existing_json["hooksConfig"] = v.clone();
        }
        if let Some(v) = new_json.get("hooks") {
            existing_json["hooks"] = v.clone();
        }

        let merged =
            serde_json::to_string_pretty(&existing_json).unwrap_or_else(|_| content.to_string());

        if existing_content == merged {
            return FileStatus::Unchanged;
        }
        if dry_run {
            return FileStatus::Updated;
        }
        match fs::write(dest, merged) {
            Ok(_) => return FileStatus::Updated,
            Err(e) => eprintln!("\n[harness] ERROR merging {}: {e}", dest.display()),
        }
        return FileStatus::Unchanged;
    }

    let unchanged = existed
        && match fs::read_to_string(dest) {
            Ok(existing) => existing == content,
            Err(e) if e.kind() == ErrorKind::PermissionDenied => {
                // File exists but we can't read it (macOS TCC). Assume unchanged —
                // a terminal with Full Disk Access likely installed it already.
                return FileStatus::Unchanged;
            }
            Err(_) => false,
        };

    if unchanged {
        return FileStatus::Unchanged;
    }
    if dry_run {
        return if existed {
            FileStatus::Updated
        } else {
            FileStatus::Added
        };
    }
    if let Some(parent) = dest.parent() {
        let _ = fs::create_dir_all(parent);
    }
    match fs::write(dest, content) {
        Ok(_) => {
            if existed {
                FileStatus::Updated
            } else {
                FileStatus::Added
            }
        }
        Err(e) if e.kind() == ErrorKind::PermissionDenied => {
            eprintln!(
                "\n[harness] WARN: permission denied writing {} — \
                 grant Full Disk Access to your terminal in System Settings > Privacy & Security, \
                 or skip global install.",
                dest.display()
            );
            FileStatus::Unchanged
        }
        Err(e) => {
            eprintln!("\n[harness] ERROR writing {}: {e}", dest.display());
            FileStatus::Unchanged
        }
    }
}

/// Make a file executable on Unix (no-op on other platforms).
fn make_executable(path: &Path) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(meta) = fs::metadata(path) {
            let mut perms = meta.permissions();
            perms.set_mode(0o755);
            let _ = fs::set_permissions(path, perms);
        }
    }
    #[cfg(not(unix))]
    {
        let _ = path; // no-op on Windows
    }
}

/// Claude global settings hooks do not run in a plugin context, so
/// `${CLAUDE_PLUGIN_ROOT}` is unavailable there.
fn sanitize_claude_global_hooks(content: &str) -> String {
    let mut json: serde_json::Value = match serde_json::from_str(content) {
        Ok(v) => v,
        Err(_) => return content.to_string(),
    };

    let Some(hooks) = json.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
        return content.to_string();
    };

    for entries in hooks.values_mut() {
        let Some(entries_arr) = entries.as_array_mut() else {
            continue;
        };
        for entry in entries_arr {
            let Some(cmd_hooks) = entry.get_mut("hooks").and_then(|v| v.as_array_mut()) else {
                continue;
            };
            for cmd_hook in cmd_hooks {
                let Some(cmd) = cmd_hook.get_mut("command").and_then(|v| v.as_str()) else {
                    continue;
                };
                // Plugin bootstrap cmd: when installed globally (not via plugin),
                // binary is already on PATH — drop the install/seed step, just resume.
                let next = if cmd.contains("setup.sh")
                    || cmd.contains("install.js")
                    || cmd.contains("epic-harness install claude")
                {
                    "epic-harness resume"
                } else {
                    cmd
                };
                if next != cmd {
                    cmd_hook["command"] = serde_json::Value::String(next.to_string());
                }
            }
        }
    }

    serde_json::to_string_pretty(&json).unwrap_or_else(|_| content.to_string())
}

// ── MCP injection ─────────────────────────────────────────────────────────────

/// Syncs canonical commands/skills into every discovered Claude Code
/// plugin cache directory (`~/.claude/plugins/cache/epicsagas/epic/*/`).
///
/// Called on `epic install claude` so the locally-installed binary always
/// keeps the cache in sync without waiting for an npm publish.
fn sync_plugin_cache(home: &str, dry_run: bool) {
    let cache_base = std::path::Path::new(home).join(".claude/plugins/cache/epicsagas/epic");

    let entries = match fs::read_dir(&cache_base) {
        Ok(e) => e,
        Err(_) => {
            return; // Claude Code not installed — skip silently
        }
    };

    // symlink 탈출 방어: cache_base를 한 번 canonicalize (루프 밖)
    let cache_base_canon = match cache_base.canonicalize() {
        Ok(p) => p,
        Err(_) => {
            return; // Claude Code not installed — skip silently
        }
    };

    let files: &[(&str, &str)] = &[
        ("commands/evolve.md", CMD_EVOLVE),
        ("commands/team.md", CMD_TEAM),
        ("commands/orbit.md", CMD_ORBIT),
        ("skills/_dispatch/SKILL.md", SKILL_DISPATCH),
        ("skills/commit/SKILL.md", SKILL_COMMIT),
        ("skills/context/SKILL.md", SKILL_CONTEXT),
        ("skills/debug/SKILL.md", SKILL_DEBUG),
        ("skills/document/SKILL.md", SKILL_DOCUMENT),
        ("skills/perf/SKILL.md", SKILL_PERF),
        ("skills/secure/SKILL.md", SKILL_SECURE),
        ("skills/simplify/SKILL.md", SKILL_SIMPLIFY),
        ("skills/tdd/SKILL.md", SKILL_TDD),
        ("skills/verify/SKILL.md", SKILL_VERIFY),
        ("skills/council/SKILL.md", SKILL_COUNCIL),
        (
            "skills/agent-introspection/SKILL.md",
            SKILL_AGENT_INTROSPECTION,
        ),
        ("skills/reflect/SKILL.md", SKILL_REFLECT),
        ("skills/discover/SKILL.md", SKILL_DISCOVER),
        ("skills/orchestrate/SKILL.md", SKILL_ORCHESTRATE),
        ("skills/spec/SKILL.md", SKILL_SPEC),
        ("skills/go/SKILL.md", SKILL_GO),
        ("skills/check/SKILL.md", SKILL_CHECK),
        ("skills/ship/SKILL.md", SKILL_SHIP),
    ];

    let mut synced = 0u32;
    for entry in entries.flatten() {
        let version_dir = entry.path();
        if !version_dir.is_dir() {
            continue;
        }
        // symlink 탈출 방어: version_dir를 canonicalize 후 cache_base 내부인지 검증
        let version_dir = match version_dir.canonicalize() {
            Ok(p) => p,
            Err(_) => continue,
        };
        if !version_dir.starts_with(&cache_base_canon) {
            eprintln!(
                "[harness] skipping suspicious cache entry: {}",
                version_dir.display()
            );
            continue;
        }
        for (rel, content) in files {
            let dest = version_dir.join(rel);
            let status = write_or_sync(&dest, content, dry_run);
            if matches!(status, FileStatus::Updated | FileStatus::Added) {
                synced += 1;
            }
        }
    }

    if dry_run {
        eprintln!("[harness] dry-run: would sync plugin cache files");
    } else {
        eprintln!("[harness] plugin cache synced ({synced} files updated)");
    }
}

/// Injects `mcpServers.harness-mem` into `~/.claude.json`.
/// Claude Code uses this file (not ~/.claude/settings.json) for global app state including MCP.
fn inject_mcp_claude() {
    let Some(home) = std::env::var_os("HOME") else {
        eprintln!("[harness] Could not determine home directory — skipping Claude MCP injection.");
        return;
    };
    let claude_json = std::path::Path::new(&home).join(".claude.json");

    let raw = if claude_json.exists() {
        fs::read_to_string(&claude_json).unwrap_or_else(|_| "{}".to_string())
    } else {
        "{}".to_string()
    };

    let mut json: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));

    if json["mcpServers"]["harness-mem"].is_object() {
        eprintln!(
            "[harness] mcpServers.harness-mem already registered in ~/.claude.json — skipping."
        );
        return;
    }

    let binary = "epic-harness";

    json["mcpServers"]["harness-mem"] = serde_json::json!({
        "command": binary,
        "args": ["mem", "mcp"]
    });

    let out = match serde_json::to_string_pretty(&json) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("[harness] Failed to serialize ~/.claude.json: {e}");
            return;
        }
    };
    let nonce = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.subsec_nanos())
        .unwrap_or(0);
    let tmp =
        claude_json.with_file_name(format!(".claude.{}.{}.json.tmp", std::process::id(), nonce));
    if fs::write(&tmp, &out).is_ok() && fs::rename(&tmp, &claude_json).is_ok() {
        eprintln!("[harness] Registered mcpServers.harness-mem in ~/.claude.json");
    } else {
        let _ = fs::remove_file(&tmp); // clean up tmp on failure
        eprintln!("[harness] Failed to write ~/.claude.json");
    }
}

/// Removes `mcpServers.harness-mem` from `~/.claude.json`.
/// Mirror of `inject_mcp_claude()` — called by `uninstall_tool` for the "claude" tool.
fn remove_mcp_claude(dry_run: bool) {
    let Some(home) = std::env::var_os("HOME") else {
        eprintln!("[harness] Could not determine home directory — skipping Claude MCP removal.");
        return;
    };
    let claude_json = std::path::Path::new(&home).join(".claude.json");

    if !claude_json.exists() {
        eprintln!("[harness] ~/.claude.json not found — nothing to remove.");
        return;
    }

    let raw = match fs::read_to_string(&claude_json) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("[harness] Failed to read ~/.claude.json: {e}");
            return;
        }
    };

    let mut json: serde_json::Value = match serde_json::from_str(&raw) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("[harness] Failed to parse ~/.claude.json: {e}");
            return;
        }
    };

    if json["mcpServers"].get("harness-mem").is_none() {
        eprintln!(
            "[harness] mcpServers.harness-mem not found in ~/.claude.json — nothing to remove."
        );
        return;
    }

    if dry_run {
        eprintln!("[harness] (dry-run) would remove mcpServers.harness-mem from ~/.claude.json");
        return;
    }

    if let Some(servers) = json["mcpServers"].as_object_mut() {
        servers.remove("harness-mem");
    }

    let out = match serde_json::to_string_pretty(&json) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("[harness] Failed to serialize ~/.claude.json: {e}");
            return;
        }
    };
    let nonce = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.subsec_nanos())
        .unwrap_or(0);
    let tmp =
        claude_json.with_file_name(format!(".claude.{}.{}.json.tmp", std::process::id(), nonce));
    if fs::write(&tmp, &out).is_ok() && fs::rename(&tmp, &claude_json).is_ok() {
        eprintln!("[harness] Removed mcpServers.harness-mem from ~/.claude.json");
    } else {
        let _ = fs::remove_file(&tmp); // clean up tmp on failure
        eprintln!("[harness] Failed to write ~/.claude.json");
    }
}

/// Injects `mcpServers.harness-mem` into the tool's settings JSON file.
/// Registers `epic-harness mem mcp` as the MCP server command (no Node.js required).
/// Silently skips if the settings file doesn't exist or already has the entry.
fn inject_mcp(tool: &str, target_dir: &Path) {
    // Claude Code stores MCP config in ~/.claude.json (global app state), not settings.json
    if tool == "claude" {
        inject_mcp_claude();
        return;
    }

    let settings_path = match tool {
        "codex" => None, // Codex uses hooks.json, no mcpServers concept
        "cursor" => Some(target_dir.join("mcp.json")),
        "opencode" => Some(target_dir.join("opencode.json")),
        "cline" => None, // Cline MCP is configured per-workspace, not via global install
        "aider" => None, // No MCP support
        _ => None,
    };

    let settings_path = match settings_path {
        Some(p) => p,
        None => return, // tool doesn't support MCP via a settings file
    };

    let raw = if settings_path.exists() {
        fs::read_to_string(&settings_path).unwrap_or_else(|_| "{}".to_string())
    } else {
        "{}".to_string()
    };

    let mut json: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));

    let binary = "epic-harness";

    // opencode uses { "mcp": { "name": { type, command[] } } }
    // Others use { "mcpServers": { "name": { command, args[] } } }
    if tool == "opencode" {
        if json["mcp"]["harness-mem"].is_object() {
            eprintln!(
                "[harness] mcp.harness-mem already registered in {tool} settings — skipping."
            );
            return;
        }
        json["mcp"]["harness-mem"] = serde_json::json!({
            "type": "local",
            "command": [binary, "mem", "mcp"]
        });
    } else {
        if json["mcpServers"]["harness-mem"].is_object() {
            eprintln!(
                "[harness] mcpServers.harness-mem already registered in {tool} settings — skipping."
            );
            return;
        }
        json["mcpServers"]["harness-mem"] = serde_json::json!({
            "command": binary,
            "args": ["mem", "mcp"]
        });
    }

    let out = serde_json::to_string_pretty(&json).unwrap_or_else(|_| raw.clone());

    if let Some(parent) = settings_path.parent() {
        let _ = fs::create_dir_all(parent);
    }
    let tmp = settings_path.with_extension("tmp");
    if fs::write(&tmp, &out).is_ok() && fs::rename(&tmp, &settings_path).is_ok() {
        eprintln!(
            "[harness] Registered mcpServers.harness-mem in {}",
            settings_path.display()
        );
    } else {
        let _ = fs::remove_file(&tmp); // clean up tmp on failure
    }
}

// ── Interactive menu ──────────────────────────────────────────────────────────

const TOOLS: &[(&str, &str)] = &[
    ("claude", "Claude Code"),
    ("codex", "OpenAI Codex CLI"),
    ("antigravity", "Google Antigravity"),
    ("cursor", "Cursor IDE"),
    ("opencode", "OpenCode"),
    ("cline", "Cline (VS Code)"),
    ("aider", "Aider"),
];

fn interactive_menu() -> Vec<String> {
    if io::stdin().is_terminal() && io::stdout().is_terminal() {
        match crate::install_wizard::interactive_select_tools(TOOLS) {
            // telemetry consent already handled inside interactive_select_tools
            Ok(selected) => selected,
            Err(e) => {
                eprintln!("[harness] Interactive UI failed ({e}); falling back to text prompt.");
                let selected = interactive_menu_fallback();
                prompt_and_save_telemetry_consent();
                selected
            }
        }
    } else {
        let selected = interactive_menu_fallback();
        prompt_and_save_telemetry_consent();
        selected
    }
}

fn prompt_and_save_telemetry_consent() {
    let level = crate::telemetry::prompt_consent_interactive();
    crate::telemetry::write_consent(level);
    match level {
        crate::telemetry::ConsentLevel::On => {
            eprintln!("[harness] Telemetry enabled. To opt out: epic-harness telemetry off");
        }
        crate::telemetry::ConsentLevel::Off => {
            eprintln!("[harness] Telemetry disabled. To enable: epic-harness telemetry on");
        }
    }
}

/// Non-TTY (CI, pipes): comma-separated indices or `a` / `all` for everything.
fn interactive_menu_fallback() -> Vec<String> {
    eprintln!();
    eprintln!("epic-harness — Select integrations to install");
    eprintln!("──────────────────────────────────────────────");
    for (i, (name, desc)) in TOOLS.iter().enumerate() {
        eprintln!("  [{}] {:<12} {}", i + 1, name, desc);
    }
    eprintln!("  [a] All of the above");
    eprintln!();
    eprint!("Selection (e.g. 1,3 or a): ");
    let _ = io::stderr().flush();

    let mut line = String::new();
    if io::stdin().read_line(&mut line).is_err() {
        return vec![];
    }
    let line = line.trim().to_lowercase();

    if line == "a" || line == "all" {
        return TOOLS.iter().map(|(name, _)| name.to_string()).collect();
    }

    let mut selected = Vec::new();
    for token in line.split(',') {
        let token = token.trim();
        if let Ok(n) = token.parse::<usize>()
            && n >= 1
            && n <= TOOLS.len()
        {
            selected.push(TOOLS[n - 1].0.to_string());
        }
    }
    selected
}

// ── Canonical file generation ─────────────────────────────────────────────────

/// Remove legacy command/agent files from a previous installation.
/// Called during install to clean up files that were absorbed into skills.
fn cleanup_legacy_files(target_dir: &Path) {
    let legacy_commands = [
        "discover",
        "spec",
        "go",
        "check",
        "ship",
        "intervene",
        "status",
    ];
    let legacy_agents = ["builder", "reviewer", "auditor", "planner"];
    for name in &legacy_commands {
        let path = target_dir.join(format!("commands/{}.md", name));
        if path.exists() {
            let _ = std::fs::remove_file(&path);
        }
    }
    for name in &legacy_agents {
        let path = target_dir.join(format!("agents/{}.md", name));
        if path.exists() {
            let _ = std::fs::remove_file(&path);
        }
    }
}

// ── Canonical file generation helpers ─────────────────────────────────────────

/// Convert a Codex prompt .md into a Codex skill SKILL.md format.
/// Extracts the description from YAML frontmatter and wraps the body.
fn prompt_to_skill(name: &str, prompt_md: &str) -> String {
    let (description, body) = if let Some(rest) = prompt_md.strip_prefix("---")
        && let Some(end) = rest.find("\n---")
    {
        let frontmatter = &rest[..end];
        let desc = frontmatter
            .lines()
            .find_map(|line| line.strip_prefix("description:"))
            .map(|d| d.trim().trim_matches('"'))
            .unwrap_or(name);
        let after = end + 4; // skip \n---
        let b = if after < rest.len() && rest.as_bytes()[after] == b'\n' {
            &rest[after + 1..]
        } else if after < rest.len() {
            &rest[after..]
        } else {
            ""
        };
        (desc.to_string(), b.to_string())
    } else {
        (name.to_string(), prompt_md.to_string())
    };

    format!("---\nname: {name}\ndescription: \"{description}\"\n---\n{body}")
}

/// Convert a Codex prompt markdown file to an Antigravity custom command (TOML).
/// Antigravity commands use `commands/{group}/{name}.toml` with a `prompt` field.
fn prompt_to_antigravity_command(_name: &str, prompt_md: &str) -> String {
    // Strip frontmatter — the body is the prompt content.
    let body = if let Some(rest) = prompt_md.strip_prefix("---")
        && let Some(end) = rest.find("\n---")
    {
        let after = end + 4;
        if after < rest.len() && rest.as_bytes()[after] == b'\n' {
            &rest[after + 1..]
        } else if after < rest.len() {
            &rest[after..]
        } else {
            ""
        }
    } else {
        prompt_md
    };

    // TOML: escape triple quotes inside the prompt by using single-quoted raw string.
    format!("prompt = '''\n{}\n'''\n", body.trim())
}

/// Generate transformed canonical skill files for a tool.
/// Returns a Vec of (relative_path, content) pairs.
fn generate_canonical_files(tool: &str) -> Vec<(String, String)> {
    let mut files = Vec::new();

    match tool {
        "codex" => {
            // Skills: transformed canonical + memory integration
            for (name, content) in CANONICAL_SKILLS {
                let transformed = transform_skill(tool, name, content);
                files.push((format!("skills/{}/SKILL.md", name), transformed));
            }
            // Command-skills: converted from deprecated prompts/ to skills/ format.
            // Prompts are no longer seeded to ~/.codex/prompts/ — skills/ is the
            // only mechanism. These appear as /evolve, /team, /orbit in the Codex UI.
            for (name, content) in CODEX_COMMAND_SKILLS {
                files.push((
                    format!("skills/{}/SKILL.md", name),
                    prompt_to_skill(name, content),
                ));
            }
        }
        "cursor" => {
            // Skills: concatenated into harness-skills.mdc
            files.push((
                "rules/harness-skills.mdc".to_string(),
                build_cursor_skills_mdc(),
            ));
        }
        "opencode" => {
            // No canonical files for opencode (only static command files)
        }
        "antigravity" => {
            // Skills: skills/{name}/SKILL.md
            for (name, content) in CANONICAL_SKILLS {
                let transformed = transform_skill(tool, name, content);
                files.push((format!("skills/{}/SKILL.md", name), transformed));
            }
            // Commands: commands/harness/{name}.toml (Antigravity custom commands)
            for (name, content) in CODEX_COMMAND_SKILLS {
                files.push((
                    format!("commands/harness/{}.toml", name),
                    prompt_to_antigravity_command(name, content),
                ));
            }
        }
        // cline, aider: no canonical files to generate
        _ => {}
    }

    files
}

// ── Install a single tool ─────────────────────────────────────────────────────

fn install_tool(tool: &str, local: bool, dry_run: bool) -> i32 {
    let cfg = match tool_config(tool) {
        Some(c) => c,
        None => {
            eprintln!(
                "[harness] Unknown tool '{tool}'. Use one of: claude, codex, antigravity, cursor, opencode, cline, aider"
            );
            return 1;
        }
    };

    let target_dir = if local {
        &cfg.local_dir
    } else {
        &cfg.global_dir
    };
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));

    if let Some(note) = cfg.note {
        eprintln!("[harness] Note: {note}");
    }

    // Generate canonical skill files
    let canonical = generate_canonical_files(tool);
    let total_files = cfg.files.len() + canonical.len();
    let mut progress = Progress::new(tool, total_files, dry_run);

    for (rel, content) in cfg.files {
        let effective_content;
        let content = if tool == "claude" && *rel == ".claude/settings.json" {
            effective_content = sanitize_claude_global_hooks(content);
            effective_content.as_str()
        } else {
            content
        };

        let dest = if cfg.root_files.contains(rel) {
            cwd.join(rel)
        } else {
            target_dir.join(rel)
        };

        let status = if cfg.root_files.contains(rel) || cfg.preserve_files.contains(rel) {
            write_if_missing(&dest, content, dry_run)
        } else {
            write_or_sync(&dest, content, dry_run)
        };

        // chmod +x for executable files (e.g. Cline hook scripts)
        if !dry_run && cfg.executable_files.contains(rel) {
            make_executable(&dest);
        }

        progress.tick(rel, status);
    }

    // Write generated canonical skill files
    for (rel, content) in &canonical {
        let dest = target_dir.join(rel);
        let status = write_or_sync(&dest, content, dry_run);
        progress.tick(rel, status);
    }

    progress.finish();

    // Inject harness-mem MCP server entry into the tool's settings file.
    if !dry_run {
        inject_mcp(tool, target_dir);
    } else {
        eprintln!("[harness] dry-run: would inject mcpServers.harness-mem into {tool} settings");
    }

    // Codex-specific: warn if config.toml exists but codex_hooks is not enabled.
    if tool == "codex" {
        let config_path = target_dir.join("config.toml");
        if config_path.exists() {
            let ok = fs::read_to_string(&config_path)
                .map(|s| s.contains("codex_hooks"))
                .unwrap_or(false);
            if !ok {
                eprintln!();
                eprintln!(
                    "[harness] WARNING: ~/.codex/config.toml exists but does not enable hooks."
                );
                eprintln!("[harness] Hooks are OFF by default. Add these lines to enable them:");
                eprintln!();
                eprintln!("    [features]");
                eprintln!("    codex_hooks = true");
                eprintln!();
                eprintln!("[harness] Then restart Codex for the change to take effect.");
            }
        }
    }

    // Sync plugin cache for Claude Code (keeps commands/skills/agents up-to-date
    // without requiring an npm publish for every change).
    if tool == "claude" {
        let home = std::env::var("HOME").unwrap_or_default();
        sync_plugin_cache(&home, dry_run);
    }

    // Seed the default epic org/team on first install (idempotent).
    // Restricted to `epic install claude` — other tools should not implicitly
    // create org state in ~/.harness/orgs/.
    if !dry_run && tool == "claude" {
        crate::team::store::install_default_team_if_needed("epic");
    }

    // Clean up legacy command/agent files from previous installations.
    if !dry_run {
        cleanup_legacy_files(target_dir);
    }

    0
}

// ── Public entry point ────────────────────────────────────────────────────────

pub fn run(args: &[String]) -> i32 {
    // Parse: epic-harness install [<tool>] [--local] [--dry-run]
    let local = args.iter().any(|a| a == "--local");
    let dry_run = args.iter().any(|a| a == "--dry-run");

    // First positional arg that isn't a flag
    let tool_arg = args
        .iter()
        .find(|a| !a.starts_with("--"))
        .map(|s| s.as_str());

    match tool_arg {
        None => {
            // Interactive menu
            let selected = interactive_menu();
            if selected.is_empty() {
                eprintln!("[harness] No integrations selected.");
                return 0;
            }
            let mut exit = 0;
            for tool in &selected {
                eprintln!("[harness] Installing {tool}...");
                let code = install_tool(tool, local, dry_run);
                if code != 0 {
                    exit = code;
                }
            }
            ensure_global_config(dry_run);
            exit
        }

        Some("--list" | "list") => {
            println!(
                "Available integrations: claude, codex, antigravity, cursor, opencode, cline, aider"
            );
            0
        }

        Some(tool) => {
            let code = install_tool(tool, local, dry_run);
            ensure_global_config(dry_run);
            code
        }
    }
}

/// Ensure `~/.harness/config.toml` and `~/.harness/HARNESS.md` exist.
/// config.toml is write-once (never overwrites user edits).
/// HARNESS.md uses write_or_sync — updated on binary upgrade, never removed on tool uninstall.
fn ensure_global_config(dry_run: bool) {
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .unwrap_or_else(|_| "/tmp".into());
    let harness_dir = std::path::Path::new(&home).join(".harness");

    if !dry_run {
        let _ = std::fs::create_dir_all(&harness_dir);
    }

    // config.toml: write-once
    let config_path = harness_dir.join("config.toml");
    if !config_path.exists() {
        if dry_run {
            eprintln!(
                "[harness] Would create {} with default configuration",
                config_path.display()
            );
        } else {
            match std::fs::write(&config_path, crate::config::default_config_template()) {
                Ok(_) => eprintln!(
                    "[harness] Created {} with default configuration",
                    config_path.display()
                ),
                Err(e) => eprintln!(
                    "[harness] Warning: could not create {}: {}",
                    config_path.display(),
                    e
                ),
            }
        }
    }

    // HARNESS.md: write_or_sync — stays current with binary upgrades
    let harness_md_path = harness_dir.join("HARNESS.md");
    let status = write_or_sync(&harness_md_path, HARNESS_MD, dry_run);
    if matches!(status, FileStatus::Added | FileStatus::Updated) {
        eprintln!("[harness] Updated {}", harness_md_path.display());
    }
}

// ── Uninstall ─────────────────────────────────────────────────────────────────

fn uninstall_tool(tool: &str, local: bool, dry_run: bool) -> i32 {
    let cfg = match tool_config(tool) {
        Some(c) => c,
        None => {
            eprintln!(
                "[harness] Unknown tool '{tool}'. Use one of: claude, codex, antigravity, cursor, opencode, cline, aider"
            );
            return 1;
        }
    };

    // Claude Code: no files to remove — only MCP entry in ~/.claude.json.
    if tool == "claude" {
        remove_mcp_claude(dry_run);
        return 0;
    }

    let target_dir = if local {
        &cfg.local_dir
    } else {
        &cfg.global_dir
    };
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));

    let mut removed = 0usize;
    let mut skipped = 0usize;

    // Collect all files: static + canonical generated
    let canonical = generate_canonical_files(tool);
    let all_files: Vec<(&str, &str)> = cfg.files.to_vec();

    for (rel, _) in &all_files {
        let dest = if cfg.root_files.contains(rel) {
            cwd.join(rel)
        } else {
            target_dir.join(rel)
        };

        // Never auto-delete root files — user may have edited them.
        if cfg.root_files.contains(rel) {
            eprintln!("  skip  {}", dest.display());
            skipped += 1;
            continue;
        }

        if dest.exists() {
            if !dry_run {
                if let Err(e) = fs::remove_file(&dest) {
                    eprintln!("\n[harness] ERROR removing {}: {e}", dest.display());
                } else {
                    removed += 1;
                }
            } else {
                removed += 1;
            }
        }
    }

    // Also remove canonical generated files
    for (rel, _) in &canonical {
        let dest = target_dir.join(rel);

        if dest.exists() {
            if !dry_run {
                if let Err(e) = fs::remove_file(&dest) {
                    eprintln!("\n[harness] ERROR removing {}: {e}", dest.display());
                } else {
                    removed += 1;
                }
            } else {
                removed += 1;
            }
        }
    }

    // Prune empty directories left behind
    if !dry_run {
        let mut dirs_to_try: Vec<PathBuf> = cfg
            .files
            .iter()
            .filter_map(|(rel, _)| {
                let dest = target_dir.join(rel);
                dest.parent().map(|p| p.to_path_buf())
            })
            .collect();
        for (rel, _) in &canonical {
            let dest = target_dir.join(rel);
            if let Some(p) = dest.parent() {
                dirs_to_try.push(p.to_path_buf());
            }
        }
        for dir in dirs_to_try {
            let _ = fs::remove_dir(&dir); // silently ignore non-empty
        }
        let _ = fs::remove_dir(target_dir);
    }

    let dry = if dry_run { " (dry-run)" } else { "" };
    eprintln!(
        "  {:<8} ✓ removed {removed} files{dry}  ({skipped} root files skipped — delete manually if needed)",
        tool
    );
    0
}

pub fn run_uninstall(args: &[String]) -> i32 {
    let local = args.iter().any(|a| a == "--local");
    let dry_run = args.iter().any(|a| a == "--dry-run");
    let tool_arg = args
        .iter()
        .find(|a| !a.starts_with("--"))
        .map(|s| s.as_str());

    match tool_arg {
        None => {
            let selected = interactive_menu();
            if selected.is_empty() {
                eprintln!("[harness] No integrations selected.");
                return 0;
            }
            let mut exit = 0;
            for tool in &selected {
                eprintln!("[harness] Uninstalling {tool}...");
                let code = uninstall_tool(tool, local, dry_run);
                if code != 0 {
                    exit = code;
                }
            }
            exit
        }
        Some("--list" | "list") => {
            println!(
                "Available integrations: claude, codex, antigravity, cursor, opencode, cline, aider"
            );
            0
        }
        Some(tool) => uninstall_tool(tool, local, dry_run),
    }
}

// ── Unit tests ────────────────────────────────────────────────────────────────

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

    fn tmp_dir() -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "epic_test_{}_{}",
            std::process::id(),
            rand_suffix()
        ));
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    /// Cheap non-crypto suffix so parallel tests don't collide.
    fn rand_suffix() -> u64 {
        use std::sync::atomic::{AtomicU64, Ordering};
        use std::time::{SystemTime, UNIX_EPOCH};
        static COUNTER: AtomicU64 = AtomicU64::new(0);
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos() as u64)
            .unwrap_or(0);
        nanos ^ (COUNTER.fetch_add(1, Ordering::Relaxed) << 32)
    }

    // ── strip_frontmatter ─────────────────────────────────────────────────────

    #[test]
    fn strip_frontmatter_no_leading_newline() {
        let md = "---\nname: test\n---\n# Content\nBody";
        let result = strip_frontmatter(md);
        assert_eq!(result, "# Content\nBody");
        assert!(!result.starts_with('\n'));
    }

    #[test]
    fn strip_frontmatter_no_frontmatter() {
        let md = "# Just content\nBody";
        assert_eq!(strip_frontmatter(md), md);
    }

    #[test]
    fn strip_frontmatter_unclosed() {
        let md = "---\nname: test\n# No closing";
        assert_eq!(strip_frontmatter(md), md);
    }

    // ── write_if_missing ──────────────────────────────────────────────────────

    #[test]
    fn test_write_if_missing_creates_new_file() {
        let dir = tmp_dir();
        let dest = dir.join("new.md");
        let status = write_if_missing(&dest, "hello", false);
        assert!(matches!(status, FileStatus::Added));
        assert!(dest.exists());
        assert_eq!(fs::read_to_string(&dest).unwrap(), "hello");
        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn test_write_if_missing_skips_existing() {
        let dir = tmp_dir();
        let dest = dir.join("existing.md");
        fs::write(&dest, "original").unwrap();
        let status = write_if_missing(&dest, "new content", false);
        assert!(matches!(status, FileStatus::Unchanged));
        assert_eq!(fs::read_to_string(&dest).unwrap(), "original");
        let _ = fs::remove_dir_all(dir);
    }

    // ── write_or_sync ─────────────────────────────────────────────────────────

    #[test]
    fn test_write_or_sync_creates_new() {
        let dir = tmp_dir();
        let dest = dir.join("brand_new.txt");
        let status = write_or_sync(&dest, "content", false);
        assert!(matches!(status, FileStatus::Added));
        assert!(dest.exists());
        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn test_write_or_sync_updates_changed() {
        let dir = tmp_dir();
        let dest = dir.join("changed.txt");
        fs::write(&dest, "old").unwrap();
        let status = write_or_sync(&dest, "new", false);
        assert!(matches!(status, FileStatus::Updated));
        assert_eq!(fs::read_to_string(&dest).unwrap(), "new");
        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn test_write_or_sync_unchanged_same_content() {
        let dir = tmp_dir();
        let dest = dir.join("same.txt");
        fs::write(&dest, "identical").unwrap();
        let status = write_or_sync(&dest, "identical", false);
        assert!(matches!(status, FileStatus::Unchanged));
        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn test_write_or_sync_dry_run_no_write() {
        let dir = tmp_dir();
        let dest = dir.join("dry.txt");
        // File does not exist; dry_run should return Added but not create file.
        let status = write_or_sync(&dest, "content", true);
        assert!(matches!(status, FileStatus::Added));
        assert!(!dest.exists());
        let _ = fs::remove_dir_all(dir);
    }

    // ── strip_frontmatter ──────────────────────────────────────────────────

    #[test]
    fn test_strip_frontmatter_removes_yaml() {
        let md = "---\nname: test\n---\n\n# Title\n\nBody";
        assert_eq!(strip_frontmatter(md), "\n# Title\n\nBody");
    }

    #[test]
    fn test_strip_frontmatter_no_frontmatter() {
        let md = "# Title\n\nBody";
        assert_eq!(strip_frontmatter(md), "# Title\n\nBody");
    }

    // ── transform_skill ──────────────────────────────────────────────────────

    #[test]
    fn test_transform_skill_codex_appends_mem_section() {
        let result = transform_skill("codex", "tdd", SKILL_TDD);
        assert!(result.contains("## Memory Integration"));
        assert!(result.contains("mem_search"));
    }

    #[test]
    fn test_transform_skill_codex_no_mem_for_debug() {
        let result = transform_skill("codex", "debug", SKILL_DEBUG);
        // Debug already has mem references in canonical; no extra section
        assert!(!result.contains("## Memory Integration\n\n**Session start"));
    }

    #[test]
    fn test_transform_skill_identity_for_cline() {
        // Cline and aider don't transform skills
        assert_eq!(transform_skill("cline", "tdd", SKILL_TDD), SKILL_TDD);
    }

    // ── build_cursor_skills_mdc ──────────────────────────────────────────────

    #[test]
    fn test_build_cursor_skills_mdc_has_frontmatter() {
        let mdc = build_cursor_skills_mdc();
        assert!(mdc.starts_with("---\n"));
        assert!(mdc.contains("alwaysApply: false"));
    }

    #[test]
    fn test_build_cursor_skills_mdc_contains_skills() {
        let mdc = build_cursor_skills_mdc();
        assert!(mdc.contains("TDD"));
        assert!(mdc.contains("Secure"));
        assert!(mdc.contains("Verify"));
        assert!(mdc.contains("Simplify"));
        assert!(mdc.contains("Perf"));
        assert!(mdc.contains("Commit"));
    }

    #[test]
    fn test_build_cursor_skills_mdc_excludes_debug_context() {
        let mdc = build_cursor_skills_mdc();
        // debug and context are operational skills, not included in cursor mdc
        assert!(!mdc.contains("# Debug"));
        assert!(!mdc.contains("# Context"));
    }

    // ── generate_canonical_files ──────────────────────────────────────────────

    #[test]
    fn test_generate_canonical_files_codex() {
        let files = generate_canonical_files("codex");
        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
        assert!(paths.contains(&"skills/tdd/SKILL.md"));
        // Consolidated skills (absorbed from commands)
        assert!(paths.contains(&"skills/spec/SKILL.md"));
        assert!(paths.contains(&"skills/go/SKILL.md"));
        assert!(paths.contains(&"skills/check/SKILL.md"));
        assert!(paths.contains(&"skills/ship/SKILL.md"));
        // Remaining command-skills
        assert!(paths.contains(&"skills/orbit/SKILL.md"));
        assert!(paths.contains(&"skills/evolve/SKILL.md"));
        assert!(paths.contains(&"skills/team/SKILL.md"));
        // No agents directory
        assert!(!paths.iter().any(|p| p.starts_with("agents/")));
        // 18 canonical skills + 3 command-skills = 21
        assert_eq!(files.len(), 18 + 3);
    }

    #[test]
    fn test_generate_canonical_files_cursor() {
        let files = generate_canonical_files("cursor");
        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
        assert!(paths.contains(&"rules/harness-skills.mdc"));
        // Cursor: 1 mdc only (no agents)
        assert_eq!(files.len(), 1);
    }

    #[test]
    fn test_generate_canonical_files_opencode() {
        let files = generate_canonical_files("opencode");
        assert!(files.is_empty()); // no canonical files for opencode
    }

    #[test]
    fn test_generate_canonical_files_cline_empty() {
        let files = generate_canonical_files("cline");
        assert!(files.is_empty());
    }

    #[test]
    fn test_generate_canonical_files_claude_empty() {
        let files = generate_canonical_files("claude");
        assert!(files.is_empty());
    }

    #[test]
    fn test_generate_canonical_files_aider_empty() {
        let files = generate_canonical_files("aider");
        assert!(files.is_empty());
    }

    #[test]
    fn test_generate_canonical_files_antigravity() {
        let files = generate_canonical_files("antigravity");
        // 18 canonical skills + 3 command-skills = 21
        assert!(
            !files.is_empty(),
            "antigravity should generate canonical files"
        );

        // Verify skills
        let skill_files: Vec<_> = files
            .iter()
            .filter(|(p, _)| p.starts_with("skills/"))
            .collect();
        assert!(
            skill_files.len() >= 16,
            "expected at least 16 skill files, got {}",
            skill_files.len()
        );

        // Verify no agents
        let agent_files: Vec<_> = files
            .iter()
            .filter(|(p, _)| p.starts_with("agents/") && p.ends_with(".md"))
            .collect();
        assert!(agent_files.is_empty(), "expected no agent files");

        // Verify commands
        let cmd_files: Vec<_> = files
            .iter()
            .filter(|(p, _)| p.starts_with("commands/harness/") && p.ends_with(".toml"))
            .collect();
        assert_eq!(cmd_files.len(), 3, "expected 3 command files");

        // Verify all command files are valid TOML (start with prompt = ''')
        for (path, content) in &cmd_files {
            assert!(
                content.starts_with("prompt = '''"),
                "command {} should start with TOML prompt field",
                path
            );
        }
    }

    #[test]
    fn test_prompt_to_antigravity_command() {
        let input = "\
---
description: \"Run tests and verify\"
---

# /check — Verify

Run all tests.
";
        let result = prompt_to_antigravity_command("check", input);
        assert!(
            result.starts_with("prompt = '''"),
            "should start with TOML prompt field"
        );
        assert!(
            result.contains("# /check — Verify"),
            "should contain body content"
        );
        assert!(
            !result.contains("description:"),
            "should not contain frontmatter"
        );
    }

    #[test]
    fn test_write_or_sync_merges_settings_json() {
        let dir = tmp_dir();
        let dest = dir.join("settings.json");
        // Existing file has a user key that must survive the merge.
        fs::write(&dest, r#"{"theme":"dark","hooksConfig":{"old":true}}"#).unwrap();
        let new_content = r#"{"hooksConfig":{"new":true}}"#;
        let status = write_or_sync(&dest, new_content, false);
        assert!(matches!(status, FileStatus::Updated));
        let written = fs::read_to_string(&dest).unwrap();
        let v: serde_json::Value = serde_json::from_str(&written).unwrap();
        // Existing key preserved.
        assert_eq!(v["theme"], "dark");
        // hooksConfig updated to new value.
        assert_eq!(v["hooksConfig"]["new"], true);
        // Old hooksConfig key gone (replaced, not merged within hooksConfig).
        assert!(v["hooksConfig"]["old"].is_null());
        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn test_write_or_sync_merges_claude_hooks() {
        let dir = tmp_dir();
        let dest = dir.join("settings.json");
        fs::write(
            &dest,
            r#"{"theme":"dark","hooks":{"SessionStart":[{"matcher":"*","hooks":[]}]}}"#,
        )
        .unwrap();
        let new_content = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[]}]}}"#;
        let status = write_or_sync(&dest, new_content, false);
        assert!(matches!(status, FileStatus::Updated));
        let written = fs::read_to_string(&dest).unwrap();
        let v: serde_json::Value = serde_json::from_str(&written).unwrap();
        assert_eq!(v["theme"], "dark");
        assert!(v["hooks"]["PreToolUse"].is_array());
        assert!(v["hooks"]["SessionStart"].is_null());
        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn test_sanitize_claude_global_hooks_removes_plugin_root_refs() {
        let out = sanitize_claude_global_hooks(CLAUDE_FILES[0].1);
        let json: serde_json::Value = serde_json::from_str(&out).unwrap();
        // All hook "command" fields must not reference setup.sh
        let hooks = json["hooks"].as_object().unwrap();
        for entries in hooks.values() {
            if let Some(arr) = entries.as_array() {
                for entry in arr {
                    if let Some(cmd_hooks) = entry["hooks"].as_array() {
                        for cmd_hook in cmd_hooks {
                            let cmd = cmd_hook["command"].as_str().unwrap_or("");
                            assert!(
                                !cmd.contains("setup.sh"),
                                "setup.sh must be removed from command: {cmd}"
                            );
                        }
                    }
                }
            }
        }
    }

    #[test]
    fn test_sanitize_claude_global_hooks_replaces_bootstrap_with_resume() {
        let out = sanitize_claude_global_hooks(CLAUDE_FILES[0].1);
        // plugin bootstrap cmd → epic-harness resume (binary already on PATH when globally installed)
        assert!(out.contains("epic-harness resume"));
    }

    // ── sync_plugin_cache ─────────────────────────────────────────────────────

    #[test]
    fn test_sync_plugin_cache_no_panic_on_missing_dir() {
        // HOME을 존재하지 않는 임시 경로로 설정 → cache_base 없음 → silent return
        let fake_home = std::env::temp_dir().join(format!("epic_test_no_cache_{}", rand_suffix()));
        // 디렉토리를 만들지 않아야 함 — cache_base read_dir 실패해야 함
        let home_str = fake_home.to_string_lossy().to_string();
        // dry_run=true 로 호출 — 패닉 없이 즉시 반환되어야 함
        sync_plugin_cache(&home_str, true);
        // 여기까지 도달하면 성공
    }

    #[test]
    fn test_sync_plugin_cache_no_panic_on_empty_cache_dir() {
        // cache_base 디렉토리는 존재하지만 version 서브디렉토리가 없는 경우
        let base_dir = tmp_dir();
        let cache_base = base_dir.join(".claude/plugins/cache/epicsagas/epic");
        fs::create_dir_all(&cache_base).unwrap();
        let home_str = base_dir.to_string_lossy().to_string();
        // 패닉 없이 종료되어야 함
        sync_plugin_cache(&home_str, true);
        let _ = fs::remove_dir_all(base_dir);
    }
}
