//! EXECUTE — the typed actions that mutate looop's world. Each is built one of two
//! ways and run through the same gated path ([`run_action`], which journals the
//! move and write-ahead-logs the intent for non-idempotent ones so a crash mid
//! side-effect is surfaced, not silently re-fired):
//!
//!   * AUTONOMOUS — looop's per-beat decide: the `tick` runner writes ONE JSON
//!     action to `.decision.json`; [`consume_decision`] parses + executes it.
//!     This is the primary driver — looop is the brain.
//!   * MANUAL — the `looop _ …` verbs (cmd_goal/sensor/playbook/run/worker)
//!     a human or client calls to steer by hand. Same [`Action`]s, same gates.
//!
//! looop is the SOLE executor either way: judgment (free to inspect) stays
//! separate from EXECUTION (gated, logged), so risky moves can be checked.

use crate::paths::Paths;
use crate::session;
use crate::store::{FileStore, Key, StateStore};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::fs;
use std::process::ExitCode;

/// One typed mutation of looop's world. Built by the `_ …` verb handlers below
/// (no longer deserialized from an LLM decision) and run through [`run_action`].
#[derive(Debug, Deserialize, PartialEq)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Action {
    /// A valid move when nothing needs doing (the decider's explicit "hold").
    Noop {
        #[serde(default)]
        reason: String,
    },
    /// The escape hatch: one ad-hoc, reversible shell command (gh query, draft,
    /// …). looop runs it (and can gate it) — arbitrary power, but ONE command,
    /// logged, not an open-ended agent session.
    RunShell {
        cmd: String,
        #[serde(default)]
        reason: String,
    },
    /// Create or update goals/<id>.md.
    WriteGoal { id: String, body: String },
    /// Move goals/<id>.md -> goals/archive/<id>.md.
    ArchiveGoal { id: String },
    /// Create or update sensors/<name>.sh (made executable).
    WriteSensor { name: String, script: String },
    /// Replace PLAYBOOK.md.
    WritePlaybook { body: String },
    /// Spawn a worker session for hands-on work.
    StartWorker { id: String, prompt: String },
}

/// Reject a file-name segment that could escape the data dir or hit a dotfile.
fn safe_segment(kind: &str, id: &str) -> Result<()> {
    if id.is_empty() || id.contains('/') || id.contains('\\') || id.starts_with('.') || id == ".." {
        bail!("invalid {kind} id {id:?}");
    }
    Ok(())
}

/// A short, stable word naming the action's category — for the typed stdout
/// line and the `action` field on the decided event.
pub fn kind(action: &Action) -> &'static str {
    match action {
        Action::Noop { .. } => "noop",
        Action::RunShell { .. } => "shell",
        Action::WriteGoal { .. } => "goal",
        Action::ArchiveGoal { .. } => "archive",
        Action::WriteSensor { .. } => "sensor",
        Action::WritePlaybook { .. } => "playbook",
        Action::StartWorker { .. } => "worker",
    }
}

/// The goal id an action targets, if any — used to stamp the per-goal activity
/// ledger that drives the `sys-goals` staleness reading (so the decider can see
/// which goals it's been neglecting and avoid starving them). Actions with no
/// goal association (noop, run_shell, write_sensor, write_playbook) return None.
fn goal_of(action: &Action) -> Option<String> {
    match action {
        Action::WriteGoal { id, .. } => Some(id.clone()),
        Action::ArchiveGoal { id } => Some(id.clone()),
        Action::StartWorker { id, .. } => Some(id.clone()),
        _ => None,
    }
}

/// Stamp `id` as acted-on "now" in the goal-activity ledger (goal id -> unix
/// secs). Best-effort: a write failure just means the staleness reading is a
/// beat stale.
fn record_goal_activity(paths: &Paths, id: &str) {
    let store = FileStore::new(paths);
    let mut map: serde_json::Map<String, serde_json::Value> = store
        .read(&Key::GoalActivity)
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();
    map.insert(id.to_string(), serde_json::json!(crate::util::now_unix()));
    let _ = store.write_atomic(
        &Key::GoalActivity,
        &serde_json::Value::Object(map).to_string(),
    );
}

/// Whether re-running this action a second time can cause a DUPLICATE,
/// non-reversible effect (a second PR comment). These are the actions the
/// write-ahead intent log guards (H: crash between the side effect and the
/// world-hash commit must not silently double-fire). Everything else is an
/// idempotent overwrite (write_goal/sensor/playbook) or has its own dedup guard
/// (start_worker's same-id alive check).
fn is_non_idempotent(action: &Action) -> bool {
    matches!(action, Action::RunShell { .. })
}

/// A stable fingerprint of a non-idempotent action's payload, so a crash report
/// names WHICH command may have half-run. Not used for dedup (the next beat's
/// AI re-decides freshly); purely diagnostic.
fn action_fingerprint(action: &Action) -> String {
    let canon = match action {
        Action::RunShell { cmd, .. } => format!("run_shell\n{cmd}"),
        _ => kind(action).to_string(),
    };
    crate::util::content_hash(canon.as_bytes())
}

/// Write the write-ahead intent record just BEFORE a non-idempotent side effect.
/// If the process dies during the effect, this file survives and is detected by
/// [`warn_if_interrupted`] on the next beat.
fn begin_intent(paths: &Paths, action: &Action) {
    let body = serde_json::json!({
        "kind": kind(action),
        "fingerprint": action_fingerprint(action),
        "ts": crate::util::now_unix(),
    })
    .to_string();
    let _ = FileStore::new(paths).write_atomic(&Key::ActionWal, &body);
}

/// Clear the intent record once execute() has returned (Ok OR Err): reaching
/// this line proves the process did not die DURING the side effect, so there is
/// nothing to recover. Only an actual crash between begin/clear leaves it.
fn clear_intent(paths: &Paths) {
    let _ = FileStore::new(paths).remove(&Key::ActionWal);
}

/// At beat start: if a write-ahead intent record survived, the previous beat
/// died mid non-idempotent side effect (run_shell) before
/// it could commit the world hash. We do NOT auto-retry (a duplicate command is
/// worse than a missed one); we surface it durably so a human can check whether
/// the command actually ran. Idempotent. Returns true when an interrupted
/// action was found and reported.
pub fn warn_if_interrupted(paths: &Paths) -> bool {
    let store = FileStore::new(paths);
    let Some(raw) = store.read(&Key::ActionWal) else {
        return false;
    };
    let _ = store.remove(&Key::ActionWal); // one-shot report
    let v: serde_json::Value = serde_json::from_str(&raw).unwrap_or_default();
    let akind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("?");
    let fp = v.get("fingerprint").and_then(|x| x.as_str()).unwrap_or("?");
    crate::util::event(
        crate::util::Level::Warn,
        "tick.interrupted",
        &format!(
            "previous beat died mid '{akind}' (a non-idempotent action) before committing \
             — NOT retried automatically; verify it didn't half-run (fp {fp})"
        ),
        &[
            ("action", serde_json::json!(akind)),
            ("fingerprint", serde_json::json!(fp)),
        ],
    );
    crate::events::emit(
        paths,
        "tick_interrupted",
        serde_json::json!({ "action": akind, "fingerprint": fp }),
    );
    true
}

fn with_trailing_newline(body: &str) -> String {
    if body.ends_with('\n') {
        body.to_string()
    } else {
        format!("{body}\n")
    }
}

/// Execute the decided action deterministically. Returns a short human summary
/// of what was done (used for the journal fallback + stdout rendering). The
/// caller owns appending the journal line and applying `next_interval_s`.
///
/// The executor is SILENT on stdout — looop renders the returned summary. Some
/// underlying calls (the worker spawn's `started …` banner, babysit's
/// send/key/restart chatter) print CLI-friendly lines; we suppress fd 1 around
/// them so raw text never leaks into the pulse's structured — and under
/// `--json`, NDJSON — stream.
pub fn execute(paths: &Paths, action: &Action) -> Result<String> {
    session::suppress_stdout(|| execute_inner(paths, action))
}

fn execute_inner(paths: &Paths, action: &Action) -> Result<String> {
    match action {
        Action::Noop { reason } => Ok(if reason.trim().is_empty() {
            "noop".to_string()
        } else {
            format!("noop · {}", reason.trim())
        }),
        Action::RunShell { cmd, reason } => {
            // `bash -c` (NOT `-lc`): a non-interactive, non-login shell sources no
            // rc files, so the command runs against looop's inherited environment
            // rather than re-running the operator's login profile every beat
            // (hermetic + cheaper).
            let out = std::process::Command::new("bash")
                .arg("-c")
                .arg(cmd)
                .current_dir(&paths.data_dir)
                .output()
                .with_context(|| format!("run_shell: {cmd}"))?;
            let code = out.status.code().unwrap_or(-1);
            let why = if reason.is_empty() { cmd } else { reason };
            if out.status.success() {
                Ok(format!("run-shell · {why}"))
            } else {
                bail!("run_shell exited {code}: {why}");
            }
        }

        Action::WriteGoal { id, body } => {
            safe_segment("goal", id)?;
            FileStore::new(paths)
                .write_atomic(&Key::Goal(id.clone()), &with_trailing_newline(body))?;
            Ok(format!("write-goal {id}"))
        }

        Action::ArchiveGoal { id } => {
            safe_segment("goal", id)?;
            FileStore::new(paths)
                .archive(&Key::Goal(id.clone()))
                .with_context(|| format!("archive_goal {id:?}"))?;
            Ok(format!("archive-goal {id}"))
        }

        Action::WriteSensor { name, script } => {
            safe_segment("sensor", name)?;
            FileStore::new(paths)
                .write_atomic(&Key::Sensor(name.clone()), &with_trailing_newline(script))?;
            Ok(format!("write-sensor {name}"))
        }

        Action::WritePlaybook { body } => {
            FileStore::new(paths).write_atomic(&Key::Playbook, &with_trailing_newline(body))?;
            Ok("write-playbook".into())
        }

        Action::StartWorker { id, prompt } => {
            // Reuse the worker-launch path (contract injection, reserved-id
            // guard, corpse reuse, detached spawn).
            let code = session::cmd_start_session(paths, &[id.clone(), prompt.clone()])?;
            if code != std::process::ExitCode::SUCCESS {
                bail!("start_worker {id:?} failed");
            }
            Ok(format!("start-worker {id}"))
        }
    }
}

/// Append one journal line in the canonical `- YYYY-MM-DD HH:MM <text>` format
/// (matching the timestamp the prompt hands the decider).
fn append_journal(paths: &Paths, line: &str) -> Result<()> {
    let stamp = crate::util::date_fmt("%Y-%m-%d %H:%M");
    FileStore::new(paths).append_line(&Key::Journal, &format!("- {stamp} {line}"))?;
    Ok(())
}

/// The decider drops its single move here (one JSON object) in the data dir;
/// looop reads it, executes it, and removes it. This is what keeps judgment
/// (the LLM, free to inspect) separate from EXECUTION (looop, the sole gated
/// actor): the move is data looop runs, not a command the model runs itself.
pub const DECISION_FILE: &str = ".decision.json";

/// One tick's decision: the action plus the metadata that rides alongside it.
#[derive(Debug, PartialEq)]
pub struct Decision {
    pub action: Action,
    /// The one journal line looop appends after executing (may be empty; the
    /// executor falls back to a generated summary).
    pub journal: String,
    /// Optional one-shot cadence nudge (seconds); NOT a move. Handed to the pulse
    /// loop in-process via `Decided.next_interval_s` (the loop clamps it).
    pub next_interval_s: Option<u64>,
}

impl Decision {
    /// Parse one decision object. `journal` / `next_interval_s` are lifted out;
    /// the remainder is decoded into the tagged `Action`.
    pub fn parse(json: &str) -> Result<Decision> {
        let v: serde_json::Value =
            serde_json::from_str(json.trim()).context("decision is not valid JSON")?;
        let journal = v
            .get("journal")
            .and_then(|x| x.as_str())
            .unwrap_or_default()
            .to_string();
        let next_interval_s = v.get("next_interval_s").and_then(|x| x.as_u64());
        let action: Action =
            serde_json::from_value(v).context("decision has no/unknown \"action\"")?;
        Ok(Decision {
            action,
            journal,
            next_interval_s,
        })
    }
}

/// What looop executed this beat: the action category, the executor's concise
/// summary, the journal line appended, and the decider's one-shot cadence nudge.
#[derive(Debug, PartialEq)]
pub struct Decided {
    pub kind: &'static str,
    pub summary: String,
    pub journal: String,
    pub next_interval_s: Option<u64>,
}

/// Read + execute the decider's `.decision.json` (one-shot: removed win or lose).
/// `None` ⇒ the decider wrote nothing (no move this beat). `Some(Err)` ⇒ a
/// malformed decision or a failed execute. Reuses [`run_action`] so the move is
/// WAL-guarded, goal-activity-stamped, and journaled exactly like a manual verb.
pub fn consume_decision(paths: &Paths) -> Option<Result<Decided>> {
    let path = paths.data_dir.join(DECISION_FILE);
    let raw = fs::read_to_string(&path).ok()?; // None ⇒ decider wrote nothing
    let _ = fs::remove_file(&path); // one-shot, win or lose
    Some((|| {
        let decision = Decision::parse(&raw)?;
        let journal = if decision.journal.trim().is_empty() {
            None
        } else {
            Some(decision.journal.as_str())
        };
        let summary = run_action(paths, &decision.action, journal)?;
        let journal_line = if decision.journal.trim().is_empty() {
            summary.clone()
        } else {
            decision.journal.clone()
        };
        Ok(Decided {
            kind: kind(&decision.action),
            summary,
            journal: journal_line,
            next_interval_s: decision.next_interval_s,
        })
    })())
}

/// Run one typed action: write-ahead-log the intent for non-idempotent moves,
/// execute, stamp per-goal activity, and append the journal line. `journal`
/// overrides the auto-generated summary as the logged "why" when non-empty.
/// Returns the executor's concise summary.
pub fn run_action(paths: &Paths, action: &Action, journal: Option<&str>) -> Result<String> {
    // Write-ahead the intent for non-idempotent actions so a crash DURING the
    // side effect is detectable next beat instead of silently re-firing.
    // clear_intent runs whether execute returns Ok or Err.
    let guarded = is_non_idempotent(action);
    if guarded {
        begin_intent(paths, action);
    }
    let exec_result = execute(paths, action);
    if guarded {
        clear_intent(paths);
    }
    let summary = exec_result?;
    if let Some(id) = goal_of(action) {
        record_goal_activity(paths, &id);
    }
    let line = match journal {
        Some(j) if !j.trim().is_empty() => j.trim().to_string(),
        _ => summary.clone(),
    };
    append_journal(paths, &line)?;
    Ok(summary)
}

/// Resolve an action body from the parsed positional words, falling back to
/// stdin when none are given OR a lone `-` is passed (so a human/client can
/// heredoc a multi-line goal/PLAYBOOK body, matching the `_ answer` convention).
/// clap already rejects mistyped flags (`_ playbook write --help` prints help
/// instead of writing the literal text), so this no longer has to guard against
/// flag-like bodies — a body that genuinely starts with `--` arrives here only
/// via the `--` end-of-options separator or stdin.
fn resolve_body(words: &[String]) -> Result<String> {
    if words.is_empty() || (words.len() == 1 && words[0] == "-") {
        use std::io::Read;
        let mut buf = String::new();
        std::io::stdin()
            .read_to_string(&mut buf)
            .context("reading body from stdin")?;
        return Ok(buf);
    }
    Ok(words.join(" "))
}

fn ok(summary: String) -> Result<ExitCode> {
    println!("{summary}");
    Ok(ExitCode::SUCCESS)
}

/// `looop _ goal write <id> [body…|-]`
pub fn write_goal(
    paths: &Paths,
    id: &str,
    body: &[String],
    journal: Option<&str>,
) -> Result<ExitCode> {
    use crate::contract::Contract;
    let body = resolve_body(body)?;
    ok(crate::contract::LocalContract::new(paths).goal_write(id, &body, journal)?)
}

/// `looop _ goal archive <id>`
pub fn archive_goal(paths: &Paths, id: &str, journal: Option<&str>) -> Result<ExitCode> {
    use crate::contract::Contract;
    ok(crate::contract::LocalContract::new(paths).goal_archive(id, journal)?)
}

/// `looop _ sensor write <name> [script…|-]`
pub fn write_sensor(
    paths: &Paths,
    name: &str,
    script: &[String],
    journal: Option<&str>,
) -> Result<ExitCode> {
    use crate::contract::Contract;
    let script = resolve_body(script)?;
    ok(crate::contract::LocalContract::new(paths).sensor_write(name, &script, journal)?)
}

/// `looop _ playbook write [body…|-]`
pub fn write_playbook(paths: &Paths, body: &[String], journal: Option<&str>) -> Result<ExitCode> {
    use crate::contract::Contract;
    let body = resolve_body(body)?;
    ok(crate::contract::LocalContract::new(paths).playbook_write(&body, journal)?)
}

/// `looop _ run [--reason TEXT] <cmd…>` — one ad-hoc, REVERSIBLE shell command.
/// The command is captured verbatim (its own `--flags` pass through), so
/// `--reason`/`--journal` must precede it.
pub fn cmd_run(paths: &Paths, args: &crate::cli::RunArgs) -> Result<ExitCode> {
    use crate::contract::Contract;
    let cmd = args.cmd.join(" ");
    if cmd.trim().is_empty() {
        eprintln!("usage: looop _ run [--reason TEXT] <cmd…>");
        return Ok(ExitCode::from(1));
    }
    let reason = args.reason.clone().unwrap_or_default();
    ok(crate::contract::LocalContract::new(paths).run(
        &cmd,
        &reason,
        args.journal.journal.as_deref(),
    )?)
}

/// `looop _ worker start <id> <prompt…|->` — spawn a worker session (journaled).
pub fn start_worker(
    paths: &Paths,
    id: &str,
    prompt: &[String],
    journal: Option<&str>,
) -> Result<ExitCode> {
    use crate::contract::Contract;
    let prompt = resolve_body(prompt)?;
    if prompt.trim().is_empty() {
        eprintln!("usage: looop _ worker start <id> <prompt…|->");
        return Ok(ExitCode::from(1));
    }
    ok(crate::contract::LocalContract::new(paths).worker_start(id, &prompt, journal)?)
}

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

    #[test]
    fn resolve_body_joins_words_and_keeps_inner_dash() {
        // Inline words join with spaces.
        assert_eq!(
            resolve_body(&["hello".to_string(), "world".to_string()]).unwrap(),
            "hello world"
        );
        // A `-` alongside real words is content, not the stdin sentinel (only a
        // LONE `-`, or no words at all, falls through to stdin — not unit-tested
        // here since it blocks on a real stdin read). clap rejects mistyped flags
        // (`--help` prints help) before they ever reach here, so the old
        // flag-like-body guard is gone; a literal `--word` body arrives only via
        // the `--` separator.
        assert_eq!(
            resolve_body(&["a".to_string(), "-".to_string(), "b".to_string()]).unwrap(),
            "a - b"
        );
    }

    #[test]
    fn safe_segment_blocks_traversal() {
        assert!(safe_segment("goal", "ok").is_ok());
        for bad in ["", "..", "a/b", ".hidden", "a\\b"] {
            assert!(safe_segment("goal", bad).is_err(), "should reject {bad:?}");
        }
    }

    #[test]
    fn run_action_write_and_archive_goal_round_trip() {
        let p = Paths::temp();
        let body = "goal: ship it\nnotes here";
        run_action(
            &p,
            &Action::WriteGoal {
                id: "ship".into(),
                body: body.into(),
            },
            None,
        )
        .unwrap();
        let written = fs::read_to_string(p.goals_dir().join("ship.md")).unwrap();
        assert_eq!(written, format!("{body}\n"), "trailing newline normalized");

        run_action(&p, &Action::ArchiveGoal { id: "ship".into() }, None).unwrap();
        assert!(!p.goals_dir().join("ship.md").exists());
        assert!(p.goals_dir().join("archive").join("ship.md").exists());
    }

    #[test]
    fn run_action_journals_and_stamps_goal_activity() {
        let p = Paths::temp();
        run_action(
            &p,
            &Action::WriteGoal {
                id: "triage".into(),
                body: "do it".into(),
            },
            Some("made triage"),
        )
        .unwrap();
        let journal = fs::read_to_string(p.journal()).unwrap();
        assert!(journal.contains("made triage"), "journal line appended");
        assert!(journal.starts_with("- "), "canonical journal prefix");
        let act: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(p.goal_activity()).unwrap()).unwrap();
        assert!(
            act.get("triage").and_then(|v| v.as_u64()).is_some(),
            "acting on a goal stamps its activity time"
        );
    }

    #[test]
    fn only_run_shell_is_guarded() {
        assert!(is_non_idempotent(&Action::RunShell {
            cmd: "gh pr comment".into(),
            reason: String::new()
        }));
        assert!(!is_non_idempotent(&Action::WriteGoal {
            id: "g".into(),
            body: "b".into()
        }));
        assert!(!is_non_idempotent(&Action::StartWorker {
            id: "w".into(),
            prompt: "p".into()
        }));
    }

    #[test]
    fn run_action_clears_wal_around_a_guarded_action() {
        let p = Paths::temp();
        run_action(
            &p,
            &Action::RunShell {
                cmd: "true".into(),
                reason: "noop check".into(),
            },
            Some("ran a guarded command"),
        )
        .unwrap();
        assert!(
            !p.action_wal().exists(),
            "the write-ahead intent is cleared once execute returns"
        );
        assert!(!warn_if_interrupted(&p), "no interrupted action to report");
    }

    #[test]
    fn warn_if_interrupted_detects_and_clears_a_stale_intent() {
        let p = Paths::temp();
        begin_intent(
            &p,
            &Action::RunShell {
                cmd: "gh pr comment 1 -b hi".into(),
                reason: String::new(),
            },
        );
        assert!(p.action_wal().exists(), "intent written before the effect");
        assert!(
            warn_if_interrupted(&p),
            "a leftover intent is reported as an interrupted beat"
        );
        assert!(!p.action_wal().exists(), "the report is one-shot");
        assert!(!warn_if_interrupted(&p));
    }

    #[test]
    fn fingerprint_is_stable_and_payload_sensitive() {
        let a = Action::RunShell {
            cmd: "echo a".into(),
            reason: "r1".into(),
        };
        let a2 = Action::RunShell {
            cmd: "echo a".into(),
            reason: "r2-ignored".into(),
        };
        let b = Action::RunShell {
            cmd: "echo b".into(),
            reason: "r1".into(),
        };
        assert_eq!(action_fingerprint(&a), action_fingerprint(&a2));
        assert_ne!(action_fingerprint(&a), action_fingerprint(&b));
    }

    #[test]
    fn goal_of_maps_only_goal_targeting_actions() {
        assert_eq!(
            goal_of(&Action::StartWorker {
                id: "triage".into(),
                prompt: "p".into()
            }),
            Some("triage".into())
        );
        assert_eq!(
            goal_of(&Action::WriteGoal {
                id: "ship".into(),
                body: "b".into()
            }),
            Some("ship".into())
        );
        assert_eq!(
            goal_of(&Action::RunShell {
                cmd: "echo hi".into(),
                reason: String::new(),
            }),
            None
        );
    }
}
