mod sandbox;

use std::{
  cmp::min,
  collections::HashMap,
  io::{self, IsTerminal, Write},
  path::Path,
  process::Stdio,
  time::{Duration, Instant},
};

use clap::{
  Parser, Subcommand, ValueEnum,
  builder::{Styles, styling::AnsiColor},
};
use colored::Colorize;
use git2::Repository;
use sandbox::{DockerContainer, GpuStatus, SandboxType};
use thiserror::Error;
use tokio::process::Command;

// =============================================================================
// Constants
// =============================================================================

const STATUS_PENDING: &str = "Status: pending\n";
const STATUS_DONE: &str = "status: done";

const SPECS_DIR: &str = "specs";
const FILE_PROMPT: &str = "prompt.md";
const FILE_STATUS: &str = "status.md";
const FILE_PLAN: &str = "implementation-plan.md";

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
enum Harness {
  #[default]
  Claude,
  Opencode,
  Codex,
}

impl Harness {
  fn as_str(self) -> &'static str {
    match self {
      Harness::Claude => "claude",
      Harness::Opencode => "opencode",
      Harness::Codex => "codex",
    }
  }
}

// =============================================================================
// Error
// =============================================================================

#[derive(Error, Debug)]
pub enum Error {
  #[error("git: {0}")]
  Git(#[from] git2::Error),
  #[error("io: {0}")]
  Io(#[from] std::io::Error),
  #[error("docker: {0}")]
  Docker(String),
  #[error("harness: {0}")]
  Harness(String),
  #[error("uncommitted changes, commit or stash first")]
  UncommittedChanges,
  #[error("specs/prompt.md not found, run `so plan` first")]
  NoPrompt,
  #[error("Dockerfile.sandbox not found")]
  NoDockerfile,
  #[error("cannot determine home directory")]
  NoHome,
  #[error("interrupted")]
  Interrupted,
  #[error("{0}")]
  Other(String),
}

// =============================================================================
// So
// =============================================================================

const STYLES: Styles = Styles::plain().header(AnsiColor::White.on_default().underline());

#[derive(Parser)]
#[command(
  name = "so",
  about = "Sandbox orchestrator for your agents",
  version,
  styles = STYLES,
  after_help = "Run 'so <command> --help' for more information on a specific command."
)]
struct Cli {
  /// Agent harness to use
  #[arg(short = 'H', long, global = true, default_value = "claude", value_enum)]
  harness: Harness,

  /// Number of iterations
  #[arg(short = 'i', long, global = true, default_value = "10")]
  iterations: u32,

  /// Model override
  #[arg(short, long, global = true)]
  model: Option<String>,

  /// Effort level for reasoning
  #[arg(short, long, global = true)]
  effort: Option<String>,

  /// Sandbox type
  #[arg(short, long, global = true, env = "SANDBOX", default_value = "docker", value_enum)]
  sandbox: SandboxType,

  #[command(subcommand)]
  command: Option<Cmd>,
}

#[derive(Subcommand)]
enum Cmd {
  /// Unattended, continuous execution
  Run,
  /// Attended, human-in-the-loop
  Step,
  /// Generate implementation plan and specs
  Plan,
  /// Fix code smells
  Clean,
  /// Remove duplicate code
  Dup,
  /// Guided learning
  Learn,
  /// Manage existing sandboxes
  Menu,
}

// =============================================================================
// Modes
// =============================================================================

#[derive(Clone, Copy)]
enum TaskMode {
  Code,
  Plan,
  Learn,
}

#[derive(Clone, Copy, PartialEq)]
enum RunMode {
  Step,
  Run,
}

enum ExecContext<'a> {
  Local { cwd: &'a Path },
  Docker { container: &'a DockerContainer, sandbox_path: &'a Path },
}

impl<'a> ExecContext<'a> {
  fn cmd(&self, program: &str) -> Command {
    self.cmd_tty(program, false)
  }

  fn cmd_tty(&self, program: &str, tty: bool) -> Command {
    match self {
      Self::Local { cwd } => {
        let mut c = Command::new(program);
        c.current_dir(cwd);
        c
      }
      Self::Docker { container, .. } => container.exec_cmd_tty(program, tty && std::io::stdin().is_terminal()),
    }
  }

  fn sandbox_path(&self) -> &Path {
    match self {
      Self::Local { cwd } => cwd,
      Self::Docker { sandbox_path, .. } => sandbox_path,
    }
  }
}

fn local_ctx<'a>(cwd: &'a Path) -> ExecContext<'a> {
  ExecContext::Local { cwd }
}

// =============================================================================
// Main
// =============================================================================

#[tokio::main]
async fn main() {
  if let Err(e) = run().await {
    eprintln!("{} {}", "error:".red().bold(), e);
    std::process::exit(1);
  }
}

async fn run() -> Result<(), Error> {
  let cli = Cli::parse();
  let h = cli.harness;
  let model = cli.model;
  let effort = cli.effort;
  let st = cli.sandbox;

  // set env vars for harness runners
  if let Some(m) = &model {
    unsafe { std::env::set_var("MODEL", m) };
  }
  if let Some(e) = &effort {
    unsafe { std::env::set_var("EFFORT", e) };
  }

  match cli.command.unwrap_or(Cmd::Menu) {
    Cmd::Run => do_run(h, cli.iterations, st).await,
    Cmd::Step => do_step(h, cli.iterations).await,
    Cmd::Plan => do_plan(h).await,
    Cmd::Clean => do_clean(h, cli.iterations, st).await,
    Cmd::Dup => do_dup(h, cli.iterations, st).await,
    Cmd::Learn => do_learn(h).await,
    Cmd::Menu => do_menu().await,
  }
}

// =============================================================================
// Validation
// =============================================================================

fn validate_sandbox(st: SandboxType) -> Result<(), Error> {
  if st == SandboxType::Bwrap {
    if cfg!(target_os = "macos") {
      return Err(Error::Other("bubblewrap not available on macOS, use --sandbox docker".into()));
    }
    if std::process::Command::new("bwrap").arg("--version").output().is_err() {
      return Err(Error::Other("bubblewrap not installed, run `sudo apt install bubblewrap`".into()));
    }
    if needs_bwrap_apparmor() {
      return Err(Error::Other("bwrap blocked by apparmor restriction".into()));
    }
  } else {
    if std::process::Command::new("docker").arg("--version").output().is_err() {
      return Err(Error::Other("docker not installed".into()));
    }
    if std::process::Command::new("docker").args(["info"]).output().map(|o| !o.status.success()).unwrap_or(true) {
      return Err(Error::Other("docker not running".into()));
    }
  }
  Ok(())
}

// check for apparmor restriction on bwrap (ubuntu 24.04+)
fn needs_bwrap_apparmor() -> bool {
  if !cfg!(target_os = "linux") {
    return false;
  }
  let restricted = std::fs::read_to_string("/proc/sys/kernel/apparmor_restrict_unprivileged_userns")
    .map(|v| v.trim() == "1")
    .unwrap_or(false);
  if !restricted {
    return false;
  }
  let profile_loaded = std::fs::read_to_string("/sys/kernel/security/apparmor/profiles")
    .map(|profiles| profiles.lines().any(|l| l.starts_with("bwrap ")))
    .unwrap_or(false);
  !profile_loaded
}

// =============================================================================
// Commands
// =============================================================================

async fn do_step(harness: Harness, iterations: u32) -> Result<(), Error> {
  let cwd = std::env::current_dir()?;
  let unattended = std::env::var("SO_UNATTENDED").is_ok();
  let mode = if unattended { RunMode::Run } else { RunMode::Step };

  let start_head = sandbox::git_head(&cwd).ok();
  let start = Instant::now();

  if !cwd.join(SPECS_DIR).join(FILE_PROMPT).exists() {
    return Err(Error::NoPrompt);
  }
  if !cwd.join(SPECS_DIR).join(FILE_STATUS).exists() {
    std::fs::write(cwd.join(SPECS_DIR).join(FILE_STATUS), STATUS_PENDING)?;
  }

  let ctx = local_ctx(&cwd);
  run_loop(mode, harness, iterations, &ctx).await?;

  if !unattended && let Some(base) = start_head {
    println!();
    print_summary(&sandbox::git_stat(&cwd, &base), Some(&fmt_time(start.elapsed())), None);
  }
  Ok(())
}

async fn do_run(harness: Harness, iterations: u32, st: SandboxType) -> Result<(), Error> {
  validate_sandbox(st)?;
  let cwd = std::env::current_dir()?;

  if sandbox::git_dirty(&cwd)? {
    return Err(Error::UncommittedChanges);
  }
  if !cwd.join(SPECS_DIR).join(FILE_PROMPT).exists() {
    return Err(Error::NoPrompt);
  }

  print_info(&format!("Creating sandbox (run) [{}]", st.as_str()));
  let sb = sandbox::Sandbox::new(&cwd, sandbox::Mode::Run, None)?;
  set_mdata(&sb.path, harness, st, &sb.task_id)?;
  println!("  {}", sb.path.display().to_string().dimmed());

  let start = Instant::now();
  let effective_max = effective_max(&sb.path, iterations);
  print_sandbox_start(harness, effective_max, st, &sb.task_id);
  finalize_sandbox(&sb, harness, effective_max, &cwd, start, st).await
}

async fn do_clean(harness: Harness, iterations: u32, st: SandboxType) -> Result<(), Error> {
  let prompt = r#"Ignore any existing specs/ files.

Review codebase super carefully with fresh eyes. Look for:
- Bugs, errors, or incorrect logic
- Code smells: unused exports, dead code, inconsistent patterns
- Confusing or unclear code

Fix one issue per iteration.
Commit: "clean: <description>"
When nothing left, set specs/status.md to "Status: done""#;
  run_with_prompt(harness, iterations, prompt, sandbox::Mode::Clean, st).await
}

async fn do_dup(harness: Harness, iterations: u32, st: SandboxType) -> Result<(), Error> {
  let prompt = r#"Ignore any existing specs/ files.
Run jscpd.
Pick one duplicate from the report.
Refactor into shared utility.
Commit: "dry: <utility name>"
When nothing left, set specs/status.md to "Status: done""#;
  run_with_prompt(harness, iterations, prompt, sandbox::Mode::Dup, st).await
}

async fn run_with_prompt(
  harness: Harness,
  iterations: u32,
  prompt: &str,
  mode: sandbox::Mode,
  st: SandboxType,
) -> Result<(), Error> {
  validate_sandbox(st)?;
  let cwd = std::env::current_dir()?;

  if sandbox::git_dirty(&cwd)? {
    return Err(Error::UncommittedChanges);
  }

  let mode_name = match mode {
    sandbox::Mode::Run => "run",
    sandbox::Mode::Clean => "clean",
    sandbox::Mode::Dup => "dup",
  };
  print_info(&format!("Creating sandbox ({}) [{}]", mode_name, st.as_str()));
  let sb = sandbox::Sandbox::new(&cwd, mode, Some(prompt))?;
  set_mdata(&sb.path, harness, st, &sb.task_id)?;
  println!("  {}", sb.path.display().to_string().dimmed());

  let start = Instant::now();
  print_sandbox_start(harness, iterations, st, &sb.task_id);
  finalize_sandbox(&sb, harness, iterations, &cwd, start, st).await
}

async fn do_plan(harness: Harness) -> Result<(), Error> {
  let cwd = std::env::current_dir()?;
  let specs = cwd.join(SPECS_DIR);
  std::fs::create_dir_all(&specs)?;

  write_if_missing(&specs.join("readme.md"), include_str!("templates/readme.md"))?;
  write_if_missing(&specs.join(FILE_PLAN), include_str!("templates/implementation-plan.md"))?;
  write_if_missing(&specs.join(FILE_PROMPT), include_str!("templates/prompt.md"))?;
  write_if_missing(&specs.join(FILE_STATUS), STATUS_PENDING)?;
  write_if_missing(&cwd.join("Dockerfile.sandbox"), include_str!("templates/Dockerfile.sandbox"))?;

  let prompt = r#"PLANNING ONLY. DO NOT IMPLEMENT.

1. Ask what I want to build
2. Search codebase for existing patterns first
3. Interview with detailed questions (1-4 at a time)
4. Generate: specs/<feature>.md, update implementation-plan.md and readme.md
5. Keep tasks atomic. Add 10+ keywords to readme.md for search.
6. When done: "Planning complete. Run so run to implement."

Never write code, only specs."#;

  println!("{}", format!("▶ Planning [{}]", harness.as_str()).cyan().bold());
  let ctx = local_ctx(&cwd);
  run_harness(harness, prompt, RunMode::Step, TaskMode::Plan, &ctx).await
}

async fn do_learn(harness: Harness) -> Result<(), Error> {
  let cwd = std::env::current_dir()?;

  let prompt = r#"Teaching mode: help me learn, don't solve for me.

Rules:
- Explain concepts, errors, and "why", not just "how"
- Ask clarifying questions about what I've tried
- Point to relevant lectures, docs, or codebase patterns
- Debug by asking guiding questions, not providing fixes
- Suggest approaches, don't implement them
- Code examples: max 2-5 lines, single concept, different names, explain each line
- Encourage adapting examples, not copying
- Never write full functions, TODO completions, assignment solutions, quiz/exam answers, or large refactors

Approach:
- First principles, build understanding from fundamentals
- Go slow, one step at a time
- Review my code and point out improvements
- Simple explanations, no unnecessary complexity

When in doubt: explain more, code less.

Start by asking what I want to learn."#;

  println!("{}", format!("▶ Learn [{}]", harness.as_str()).cyan().bold());
  let ctx = local_ctx(&cwd);
  run_harness(harness, prompt, RunMode::Step, TaskMode::Learn, &ctx).await
}

async fn do_menu() -> Result<(), Error> {
  loop {
    let sandboxes = sandbox::list()?;
    if sandboxes.is_empty() {
      println!("No active sandboxes.");
      println!("Run 'so run' to start a new sandbox.");
      return Ok(());
    }

    // group by project
    let mut projects: HashMap<String, Vec<&sandbox::Info>> = HashMap::new();
    for sb in &sandboxes {
      let project = sb
        .name
        .strip_prefix("sandbox-")
        .and_then(|s| s.rsplit_once('-'))
        .map(|(p, _)| p.to_string())
        .unwrap_or_else(|| sb.name.clone());
      projects.entry(project).or_default().push(sb);
    }

    let mut project_order: Vec<(String, std::time::SystemTime)> = projects
      .iter()
      .map(|(name, sbs)| {
        let newest = sbs.iter().map(|sb| sb.created).max().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
        (name.clone(), newest)
      })
      .collect();
    project_order.sort_by(|a, b| b.1.cmp(&a.1));

    println!("{}", "Sandboxes:".white().bold());
    let mut all: Vec<&sandbox::Info> = Vec::new();
    let mut idx = 1;

    for (project, _) in &project_order {
      if let Some(sbs) = projects.get(project) {
        println!("\n  {}", project.magenta().bold());
        for sb in sbs {
          all.push(sb);
          let age = sb.created.elapsed().map(|d| d.as_secs() / 60).unwrap_or(0);
          let status_pad = format!("{:<8}", sb.status);
          let status_col = match sb.status.as_str() {
            "done" => status_pad.green(),
            "blocked" => status_pad.red(),
            "pending" => status_pad.yellow(),
            _ => status_pad.dimmed(),
          };
          println!(
            "    {}. {}  {} {}",
            format!("{:>2}", idx).cyan().bold(),
            sb.name,
            status_col,
            fmt_age(age).dimmed()
          );
          idx += 1;
        }
      }
    }
    println!();

    println!(
      " {}  {}lean  {}uit",
      format!("[1-{}]", all.len()).cyan().bold(),
      "[c]".cyan().bold(),
      "[q]".cyan().bold()
    );
    print!("> ");
    io::stdout().flush()?;
    let input = read_line_trim()?;
    if input.is_empty() {
      continue;
    }
    let lower = input.to_lowercase();

    if lower == "q" {
      return Ok(());
    }
    if lower == "c" {
      let mut project_names: Vec<_> = projects.keys().cloned().collect();
      project_names.sort();

      println!("Clean:");
      for (i, name) in project_names.iter().enumerate() {
        let count = projects.get(name).map(|v| v.len()).unwrap_or(0);
        println!("  {}. {:<16} ({} sandboxes)", format!("{:>2}", i + 1).cyan().bold(), name, count);
      }
      println!();
      println!(" {}ll    {}uit", "[a]".cyan().bold(), "[q]".cyan().bold());
      print!("Select project to clean: ");
      io::stdout().flush()?;
      let choice = read_line_trim()?;
      let choice_lower = choice.to_lowercase();

      if choice_lower == "q" {
        continue;
      }
      if choice_lower == "a" {
        if confirm(&format!("Delete all {} sandboxes?", all.len()))? {
          for sb in &all {
            let _ = std::fs::remove_dir_all(&sb.path);
          }
          println!("\nAll sandboxes cleaned.");
          return Ok(());
        }
        println!();
        continue;
      }
      if let Ok(n) = choice.parse::<usize>()
        && n >= 1
        && n <= project_names.len()
      {
        let project = &project_names[n - 1];
        let group = projects.get(project).cloned().unwrap_or_default();
        if confirm(&format!("Delete {} sandboxes for \"{}\"?", group.len(), project))? {
          for sb in group {
            let _ = std::fs::remove_dir_all(&sb.path);
          }
          println!("\nSandboxes cleaned for {}.", project);
          return Ok(());
        }
        println!();
        continue;
      }
      println!();
      continue;
    }
    if let Ok(n) = input.parse::<usize>()
      && n >= 1
      && n <= all.len()
    {
      let sb = all[n - 1];
      let branch = sandbox::git_branch(&sb.path).unwrap_or_else(|_| "sandbox/main".into());
      let base = sandbox::git_base(&sb.path, sandbox::BASE_TAG);
      println!();
      print_summary(&sandbox::git_stat(&sb.path, &base), None, Some(&sb.path.display().to_string()));
      run_menu(&sb.path, &base, &sb.original, &branch).await?;
      return Ok(());
    }
    eprintln!("{} invalid choice", "error:".red().bold());
  }
}

// =============================================================================
// Run loop
// =============================================================================

async fn run_loop(mode: RunMode, harness: Harness, max: u32, ctx: &ExecContext<'_>) -> Result<(), Error> {
  let cwd = ctx.sandbox_path();
  let prompt_path = cwd.join(SPECS_DIR).join(FILE_PROMPT);
  let status_path = cwd.join(SPECS_DIR).join(FILE_STATUS);
  let unattended = std::env::var("SO_UNATTENDED").is_ok();
  let effective_max = effective_max(cwd, max);

  for i in 1..=effective_max {
    if check_status(cwd)? {
      if let Ok(c) = std::fs::read_to_string(&status_path) {
        let status = c.trim();
        if !status.is_empty() {
          println!("{}", status);
        }
        if is_done(status) {
          println!("All tasks complete.");
        }
      }
      break;
    }

    print_header(harness, i, effective_max);
    let iter_start = Instant::now();

    let base = std::fs::read_to_string(&prompt_path)?;
    let prompt = if mode == RunMode::Step {
      format!("{}\n\nDo not commit, human will handle that.\n\n---\nIteration {}/{}.", base, i, effective_max)
    } else {
      let commits = sandbox::git_recent(cwd, sandbox::BASE_TAG, 10);
      format!("{}\n\nRecent commits:\n{}\n\n---\nIteration {}/{}.", base, commits, i, effective_max)
    };

    run_harness(harness, &prompt, mode, TaskMode::Code, ctx).await?;

    if unattended {
      enforce_commit(harness, ctx).await;
    }

    print_header_time(harness, i, effective_max, &fmt_time(iter_start.elapsed()));

    if i < effective_max {
      if mode == RunMode::Step {
        if !confirm("Continue?")? {
          println!("\nStopped.");
          break;
        }
        println!();
      } else {
        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
      }
    }
  }
  Ok(())
}

fn task_count(cwd: &Path) -> Option<u32> {
  let plan = cwd.join(SPECS_DIR).join(FILE_PLAN);
  let content = std::fs::read_to_string(plan).ok()?;
  let count = content.lines().filter(|l| l.trim_start().starts_with("- [ ]")).count();
  if count == 0 { None } else { Some(count as u32) }
}

// =============================================================================
// Harness
// =============================================================================

async fn run_harness(
  harness: Harness,
  prompt: &str,
  mode: RunMode,
  task: TaskMode,
  ctx: &ExecContext<'_>,
) -> Result<(), Error> {
  match harness {
    Harness::Claude => run_claude(prompt, mode, ctx).await,
    Harness::Opencode => run_opencode(prompt, mode, task, ctx).await,
    Harness::Codex => run_codex(prompt, mode, task, ctx).await,
  }
}

async fn run_claude(prompt: &str, mode: RunMode, ctx: &ExecContext<'_>) -> Result<(), Error> {
  let mut cmd = ctx.cmd("claude");
  if let Ok(m) = std::env::var("MODEL") {
    cmd.arg("--model").arg(m);
  }

  if mode == RunMode::Step {
    cmd.stdin(Stdio::piped()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
    let mut child = cmd.spawn().map_err(|e| harness_err("claude", e))?;
    if let Some(mut stdin) = child.stdin.take() {
      use tokio::io::AsyncWriteExt;
      stdin.write_all(prompt.as_bytes()).await?;
    }
    wait_child(child, "claude").await
  } else {
    cmd.args(["--dangerously-skip-permissions", "--setting-sources", "project,local"]);
    cmd.arg("--settings").arg(r#"{"outputStyle":"Explanatory","alwaysThinkingEnabled":true}"#);
    cmd.args(["-p", "--verbose", "--output-format", "stream-json"]).arg(prompt);
    cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::inherit());

    // parse streaming JSON output
    let mut child = cmd.spawn().map_err(|e| harness_err("claude", e))?;
    let reader = child.stdout.take().map(|stdout| {
      tokio::spawn(async move {
        use tokio::io::{AsyncBufReadExt, BufReader};
        let mut lines = BufReader::new(stdout).lines();
        while let Ok(Some(line)) = lines.next_line().await {
          if !line.starts_with('{') {
            continue;
          }
          let j = match serde_json::from_str::<serde_json::Value>(&line) {
            Ok(v) => v,
            Err(_) => continue,
          };
          if j.get("type").and_then(|t| t.as_str()) != Some("assistant") {
            continue;
          }
          let content = match j.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array()) {
            Some(c) => c,
            None => continue,
          };
          for item in content {
            if item.get("type").and_then(|t| t.as_str()) != Some("text") {
              continue;
            }
            if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
              println!("{}\n", text);
            }
          }
        }
      })
    });
    let result = wait_child(child, "claude").await;
    if let Some(reader) = reader {
      let _ = reader.await;
    }
    result
  }
}

async fn run_opencode(prompt: &str, mode: RunMode, task: TaskMode, ctx: &ExecContext<'_>) -> Result<(), Error> {
  let (model, effort) = resolve_model_effort(
    task,
    ("openai/gpt-5.2", "high"),
    ("openai/gpt-5.2", "medium"),
    ("openai/gpt-5.2-codex", "medium"),
  );

  let mut cmd = ctx.cmd_tty("opencode", true);
  if mode == RunMode::Step {
    cmd.args(["--prompt", prompt, "-m", &model]);
  } else {
    // for local, set env here
    // for docker, it's set in container startup
    if matches!(ctx, ExecContext::Local { .. }) {
      cmd.env("OPENCODE_PERMISSION", r#"{"*":"allow"}"#);
    }
    cmd.args(["run", "--log-level", "ERROR", "-m", &model, "--variant", &effort, prompt]);
  }
  cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
  let child = cmd.spawn().map_err(|e| harness_err("opencode", e))?;
  wait_child(child, "opencode").await
}

async fn run_codex(prompt: &str, mode: RunMode, task: TaskMode, ctx: &ExecContext<'_>) -> Result<(), Error> {
  let (model, effort) =
    resolve_model_effort(task, ("gpt-5.2", "high"), ("gpt-5.2", "medium"), ("gpt-5.2-codex", "medium"));

  let mut cmd = ctx.cmd("codex");
  let cfg = format!("model_reasoning_effort={}", effort);
  let bypass = "--dangerously-bypass-approvals-and-sandbox";
  if mode == RunMode::Step {
    cmd.args([prompt, "--model", &model, bypass, "--config", &cfg]);
  } else {
    cmd.args(["exec", bypass, prompt, "--model", &model, "--config", &cfg]);
  }
  cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
  let child = cmd.spawn().map_err(|e| harness_err("codex", e))?;
  wait_child(child, "codex").await
}

fn harness_err(name: &str, e: std::io::Error) -> Error {
  if e.kind() == std::io::ErrorKind::NotFound {
    Error::Harness(format!("`{}` not found", name))
  } else {
    Error::Harness(format!("failed to run `{}`: {}", name, e))
  }
}

fn check_status_code(status: std::process::ExitStatus, name: &str) -> Result<(), Error> {
  // use sigint (ctrl+c) and sigterm for graceful shutdown, not failure
  #[cfg(unix)]
  {
    use std::os::unix::process::ExitStatusExt;
    if status.signal().is_some_and(|sig| sig == libc::SIGINT || sig == libc::SIGTERM) {
      return Ok(());
    }
    if status.code().is_some_and(|c| c == 130 || c == 143) {
      return Ok(());
    }
  }
  if status.success() { Ok(()) } else { Err(Error::Harness(format!("`{}` failed", name))) }
}

// wait for child process, treating ctrl+c as normal exit
async fn wait_child(mut child: tokio::process::Child, name: &str) -> Result<(), Error> {
  use tokio::signal;
  tokio::select! {
    biased;
    res = child.wait() => check_status_code(res.map_err(|e| harness_err(name, e))?, name),
    _ = signal::ctrl_c() => {
      println!();
      let _ = child.kill().await;
      let _ = child.wait().await;
      Ok(())
    }
  }
}

fn resolve_model_effort(
  task: TaskMode,
  plan: (&'static str, &'static str),
  learn: (&'static str, &'static str),
  code: (&'static str, &'static str),
) -> (String, String) {
  let (def_model, def_effort) = match task {
    TaskMode::Plan => plan,
    TaskMode::Learn => learn,
    TaskMode::Code => code,
  };
  let model = std::env::var("MODEL").unwrap_or_else(|_| def_model.into());
  let effort = std::env::var("EFFORT").unwrap_or_else(|_| def_effort.into());
  (model, effort)
}

// =============================================================================
// Helpers
// =============================================================================

async fn enforce_commit(harness: Harness, ctx: &ExecContext<'_>) {
  let cwd = ctx.sandbox_path();
  let msg = "Commit now. Message format:\n- What: <what was done>\n- Why: <reasoning>\n- Alternatives: <what else was considered>";
  for i in 1..=3 {
    if !sandbox::git_dirty(cwd).unwrap_or(false) {
      return;
    }
    eprintln!("\n{} uncommitted changes ({}/3)\n", "warning:".yellow().bold(), i);
    if run_harness(harness, msg, RunMode::Run, TaskMode::Code, ctx).await.is_err() {
      break;
    }
  }
  if sandbox::git_dirty(cwd).unwrap_or(false) {
    eprintln!("\n{} failed to commit after 3 attempts\n", "error:".red().bold());
  }
}

fn check_status(cwd: &Path) -> Result<bool, Error> {
  let p = cwd.join(SPECS_DIR).join(FILE_STATUS);
  if !p.exists() {
    return Ok(false);
  }
  let c = std::fs::read_to_string(&p)?.to_lowercase();
  Ok(c.contains(STATUS_DONE))
}

fn read_status(cwd: &Path) -> Option<String> {
  let p = cwd.join(SPECS_DIR).join(FILE_STATUS);
  std::fs::read_to_string(&p).ok().map(|s| s.trim().to_string())
}

fn is_done(status: &str) -> bool {
  status.to_lowercase().contains(STATUS_DONE)
}

fn set_status_pending(cwd: &Path) -> Result<(), Error> {
  std::fs::write(cwd.join(SPECS_DIR).join(FILE_STATUS), STATUS_PENDING)?;
  Ok(())
}

fn write_if_missing(path: &Path, content: &str) -> Result<(), Error> {
  if !path.exists() {
    std::fs::write(path, content)?;
  }
  Ok(())
}

fn effective_max(cwd: &Path, max: u32) -> u32 {
  task_count(cwd).map(|n| min(max, n)).unwrap_or(max)
}

async fn finalize_sandbox(
  sb: &sandbox::Sandbox,
  harness: Harness,
  iterations: u32,
  cwd: &Path,
  start: Instant,
  st: SandboxType,
) -> Result<(), Error> {
  match run_sandbox_iterations(sb, harness, iterations, st).await {
    Ok(outcome) => {
      println!();
      if matches!(outcome, RunOutcome::Interrupted) {
        println!("{}", "Interrupted.".yellow());
      }
      print_summary(
        &sandbox::git_stat(&sb.path, sandbox::BASE_TAG),
        Some(&fmt_time(start.elapsed())),
        Some(&sb.path.display().to_string()),
      );
      run_menu(&sb.path, sandbox::BASE_TAG, cwd, &sb.branch).await?;
      Ok(())
    }
    Err(e) => {
      let _ = std::fs::remove_dir_all(&sb.path);
      Err(e)
    }
  }
}

fn read_line_trim() -> io::Result<String> {
  let mut buf = String::new();
  io::stdin().read_line(&mut buf)?;
  Ok(buf.trim().to_string())
}

fn confirm(msg: &str) -> io::Result<bool> {
  print!("{} [y/n] ", msg);
  io::stdout().flush()?;
  Ok(read_line_trim()?.to_lowercase().starts_with('y'))
}

fn short_hash(h: &str) -> &str {
  if h.len() >= 7 { &h[..7] } else { h }
}

fn run_git(args: &[&str], cwd: &Path) -> bool {
  match std::process::Command::new("git")
    .args(args)
    .current_dir(cwd)
    .stdin(Stdio::inherit())
    .stdout(Stdio::inherit())
    .stderr(Stdio::inherit())
    .status()
  {
    Ok(s) => s.success(),
    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
      eprintln!("{} git not installed", "error:".red().bold());
      false
    }
    Err(_) => false,
  }
}

enum RunOutcome {
  Completed,
  Interrupted,
}

async fn run_sandbox_iterations(
  sb: &sandbox::Sandbox,
  harness: Harness,
  iterations: u32,
  st: SandboxType,
) -> Result<RunOutcome, Error> {
  let outcome = |result: Result<(), Error>| match result {
    Ok(()) => Ok(RunOutcome::Completed),
    Err(Error::Interrupted) => Ok(RunOutcome::Interrupted),
    Err(e) => Err(e),
  };
  match st {
    SandboxType::Docker => {
      let container = sandbox::start_docker(sb).await?;
      let ctx = ExecContext::Docker { container: &container, sandbox_path: &sb.path };
      let result = run_loop(RunMode::Run, harness, iterations, &ctx).await;
      container.stop().await;
      outcome(result)
    }
    SandboxType::Bwrap => outcome(sandbox::run_bwrap(sb, harness.as_str(), iterations).await),
  }
}

struct Mdata {
  harness: Harness,
  sandbox: SandboxType,
  task_id: String,
}

fn set_mdata(path: &Path, harness: Harness, sandbox: SandboxType, task_id: &str) -> Result<(), Error> {
  let repo = Repository::open(path)?;
  let mut cfg = repo.config()?;
  cfg.set_str("so.mdata.harness", harness.as_str())?;
  cfg.set_str("so.mdata.sandbox", sandbox.as_str())?;
  cfg.set_str("so.mdata.task-id", task_id)?;
  Ok(())
}

fn read_mdata(path: &Path) -> Result<Mdata, Error> {
  let repo = Repository::open(path)?;
  let cfg = repo.config()?;
  let harness = cfg.get_string("so.mdata.harness").ok().and_then(|v| parse_harness(&v));
  let sandbox = cfg.get_string("so.mdata.sandbox").ok().and_then(|v| parse_sandbox_type(&v));
  let task_id = cfg.get_string("so.mdata.task-id").ok();
  match (harness, sandbox, task_id) {
    (Some(h), Some(s), Some(t)) => Ok(Mdata { harness: h, sandbox: s, task_id: t }),
    _ => Err(Error::Other("sandbox metadata missing, start a new run".into())),
  }
}

fn parse_harness(s: &str) -> Option<Harness> {
  match s.to_lowercase().as_str() {
    "claude" => Some(Harness::Claude),
    "opencode" => Some(Harness::Opencode),
    "codex" => Some(Harness::Codex),
    _ => None,
  }
}

fn parse_sandbox_type(s: &str) -> Option<SandboxType> {
  match s.to_lowercase().as_str() {
    "docker" => Some(SandboxType::Docker),
    "bwrap" => Some(SandboxType::Bwrap),
    _ => None,
  }
}

// =============================================================================
// Sandbox menu
// =============================================================================

async fn run_menu(sandbox: &Path, base: &str, orig: &Path, branch: &str) -> Result<(), Error> {
  loop {
    println!(
      " {}iff  {}hell  {}eset  {}erge  {}ontinue  {}uit",
      "[d]".cyan().bold(),
      "[s]".cyan().bold(),
      "[r]".cyan().bold(),
      "[m]".cyan().bold(),
      "[c]".cyan().bold(),
      "[q]".cyan().bold()
    );
    print!("> ");
    io::stdout().flush()?;
    let input = read_line_trim()?;
    if input.is_empty() {
      continue;
    }
    let ch = input.to_lowercase().chars().next().unwrap_or(' ');

    match ch {
      'd' => {
        let range = format!("{}..HEAD", base);
        run_git(&["diff", &range], sandbox);
        println!();
      }
      's' => {
        let shell = match std::env::var("SHELL") {
          Ok(s) => s,
          Err(_) => {
            println!(" {}", "SHELL not set".red());
            continue;
          }
        };
        println!("{}", "Entering sandbox. Type 'exit' to return".yellow());
        match std::process::Command::new(&shell).current_dir(sandbox).status() {
          Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            println!(" {}", format!("Shell not found: {}", shell).red());
          }
          _ => {}
        }
        println!();
      }
      'r' => {
        println!("{}", "Commits since base:".white().bold());
        let commits = sandbox::git_commits(sandbox, base)?;
        if commits.is_empty() {
          println!("  No commits to reset.\n");
          continue;
        }
        for (i, (h, m)) in commits.iter().enumerate() {
          println!("  {}. {} {}", format!("{:>2}", i + 1).cyan().bold(), short_hash(h), m);
        }
        println!();
        print!("Reset to [1-{}] [q]uit ", commits.len());
        io::stdout().flush()?;
        let input = read_line_trim()?;
        if input.to_lowercase() != "q"
          && let Ok(n) = input.parse::<usize>()
          && n >= 1
          && n <= commits.len()
        {
          let (h, m) = &commits[n - 1];
          if run_git(&["reset", "--hard", h], sandbox) {
            println!("{}", format!("Reset to: {} {}", short_hash(h), m).green());
          }
        }
        println!();
      }
      'm' => {
        let cur = sandbox::git_branch(orig).unwrap_or_else(|_| "main".into());
        let dirty = sandbox::git_dirty(orig).unwrap_or(false);
        println!();
        println!(" Branch: {}", cur.yellow().bold());
        if dirty {
          println!(" {}", "Uncommitted changes. Commit or stash first".red());
          println!();
          continue;
        }
        if !confirm(" Merge?")? {
          println!();
          continue;
        }
        println!();

        let fetch =
          std::process::Command::new("git").args(["-C"]).arg(orig).args(["fetch"]).arg(sandbox).arg(branch).status();
        match fetch {
          Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            println!(" {}", "Git not installed".red());
            continue;
          }
          Ok(s) if !s.success() => {
            println!(" {}", "Fetch failed".red());
            continue;
          }
          Err(_) => {
            println!(" {}", "Fetch failed".red());
            continue;
          }
          _ => {}
        }

        let merge =
          std::process::Command::new("git").args(["-C"]).arg(orig).args(["merge", "--squash", "FETCH_HEAD"]).status();
        let merge_ok = match &merge {
          Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            println!(" {}", "Git not installed".red());
            false
          }
          Ok(s) => s.success(),
          Err(_) => false,
        };
        if merge_ok {
          run_git(&["checkout", "HEAD", "--", ".gitignore"], orig);
          if sandbox.join("specs").exists() {
            let _ = sandbox::copy_dir(&sandbox.join("specs"), &orig.join("specs"));
          }
          let _ = std::fs::remove_dir_all(sandbox);
          let staged = std::process::Command::new("git")
            .args(["-C"])
            .arg(orig)
            .args(["diff", "--cached", "--name-only"])
            .output()
            .map(|o| String::from_utf8_lossy(&o.stdout).lines().count())
            .unwrap_or(0);
          println!(" {}", format!("{} files staged. Sandbox cleaned", staged).green());
        } else {
          println!(" {}", format!("Conflict. Sandbox kept at: {}", sandbox.display()).red());
        }
        break;
      }
      'c' => {
        if let Some(status) = read_status(sandbox)
          && is_done(&status)
        {
          if let Some(pending) = task_count(sandbox) {
            if pending > 0 {
              set_status_pending(sandbox)?;
            } else {
              eprintln!("{} all tasks complete", "error:".red().bold());
              println!();
              continue;
            }
          } else {
            eprintln!("{} all tasks complete", "error:".red().bold());
            println!();
            continue;
          }
        }
        let mdata = match read_mdata(sandbox) {
          Ok(m) => m,
          Err(e) => {
            eprintln!("{} {}", "error:".red().bold(), e);
            println!();
            continue;
          }
        };
        print!("Iterations? [q]uit (default 10): ");
        io::stdout().flush()?;
        let input = read_line_trim()?;
        let lower = input.to_lowercase();
        if lower == "q" {
          println!();
          continue;
        }
        let n = if input.is_empty() {
          10
        } else {
          match input.parse::<u32>() {
            Ok(v) if v > 0 => v,
            _ => {
              println!(" {}", "invalid iterations".red());
              println!();
              continue;
            }
          }
        };

        validate_sandbox(mdata.sandbox)?;
        let sb = sandbox::Sandbox {
          path: sandbox.to_path_buf(),
          original: orig.to_path_buf(),
          branch: branch.into(),
          task_id: mdata.task_id.clone(),
        };
        let start = Instant::now();
        print_sandbox_start(mdata.harness, n, mdata.sandbox, &mdata.task_id);
        match run_sandbox_iterations(&sb, mdata.harness, n, mdata.sandbox).await {
          Ok(RunOutcome::Completed) => {
            println!();
            print_summary(
              &sandbox::git_stat(&sb.path, base),
              Some(&fmt_time(start.elapsed())),
              Some(&sb.path.display().to_string()),
            );
            println!();
          }
          Ok(RunOutcome::Interrupted) => {
            println!("\n{}", format!("Interrupted. Sandbox kept at: {}", sb.path.display()).yellow());
          }
          Err(e) => return Err(e),
        }
      }
      'q' => break,
      _ => {}
    }
  }
  Ok(())
}

// =============================================================================
// Formatting
// =============================================================================

fn fmt_time(d: Duration) -> String {
  let s = d.as_secs();
  if s >= 60 { format!("{}m {:02}s", s / 60, s % 60) } else { format!("{}s", s) }
}

fn fmt_age(mins: u64) -> String {
  if mins < 60 {
    format!("{}m", mins)
  } else if mins < 1440 {
    format!("{}h", mins / 60)
  } else {
    format!("{}d", mins / 1440)
  }
}

fn print_info(msg: &str) {
  println!("{}", format!("▶ {}", msg).yellow().bold());
}

fn print_header(harness: Harness, cur: u32, total: u32) {
  println!("{}", format!("▶ [{}] {}/{}", harness.as_str(), cur, total).cyan().bold());
}

fn print_header_time(harness: Harness, cur: u32, total: u32, time: &str) {
  println!("{}", format!("▶ [{}] {}/{} | {}", harness.as_str(), cur, total, time).cyan().bold());
}

fn print_sandbox_start(harness: Harness, n: u32, st: SandboxType, task_id: &str) {
  println!("{}", format!("▶ Starting [{}] max={} [{}]", harness.as_str(), n, st.as_str()).green().bold());
  if harness == Harness::Claude {
    println!("{}", format!("  Tasks: {}", task_id).magenta());
  }
  // warn if gpu driver present but docker toolkit missing (linux + docker only)
  if cfg!(target_os = "linux") && st == SandboxType::Docker && sandbox::check_gpu() == GpuStatus::MissingToolkit {
    eprintln!("{} docker gpu support not configured, running without gpu", "warning:".yellow().bold());
  }
  println!();
}

fn print_summary(stats: &str, time: Option<&str>, path: Option<&str>) {
  let line = "━".repeat(48).white().bold();
  println!("{}", line);
  if let Some(p) = path {
    println!(" {}", p.yellow());
  }
  if let Some(t) = time {
    println!(" {} | {}", stats, t);
  } else {
    println!(" {}", stats);
  }
  println!("{}", line);
}
