//! DECIDE — assemble the one-tick prompt: the PLAYBOOK, goals, sensor readings,
//! worker sessions, claims and recent journal. A faithful port of the bash
//! `build_prompt`. The instruction text is verbatim; only the marked dynamic
//! fields (data dir, binary path, local-time strings) are substituted.

use crate::paths::Paths;
use crate::session;
use crate::util;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};

const INSTRUCTIONS: &str = r#"You are "looop", a personal operations agent. This is one tick of a loop; your
process is disposable. Your working directory is the loop's DATA dir
(__DATA__): goals/, journal.md and sensors/ are here; edit with relative paths.

Read the PLAYBOOK, goals, sensor readings and sessions below, then make exactly
ONE move — the single most important one — and stop.

Moves:
- do a small reversible action directly (gh commands, drafts, queries)
- create / update / archive a goal (files in goals/; archive = move to goals/archive/)
- write or adjust an sensor script in sensors/ when you need a new view of the
  world; it runs from next tick. CONTRACT: print ONE small, NORMALIZED JSON
  object to stdout (capped ~8KB — it is cat'd into this prompt every beat, so a
  raw dump inflates context + cost). To avoid waking the loop on noise, split
  volatile fields out: {"signal":{… only the state that should trigger a move…},
  "detail":{… counts/timestamps/extra context…}} — only .signal feeds the
  change-detection hash, while the whole object still reaches this prompt.
- start a worker session for hands-on work (runs an agent under babysit, in the
  data dir):
    __BIN__ start-session <id> "<detailed prompt for the worker>"
  <id> matches the goal file name. The worker starts in the data dir; if its
  task edits CODE it must provision its OWN sandbox first (box if available, else
  git worktree) and cd in — say so in the prompt. Never edit code in the data dir.
- observe / steer an EXISTING session instead of spawning a new one. The live
  sessions are already listed below, but you can look closer for free (these are
  read-only and do NOT count as your move):
    __BIN__ shot <id>            its current visible screen
    __BIN__ log <id> --tail 40   its recent output
  And you MAY drive one as your single move when a live worker already owns the
  task and just needs a nudge (steer it, send a value it asked of YOU not the
  human, or interrupt a wedged run):
    __BIN__ send <id> "<text>"   type into its stdin
    __BIN__ key  <id> Enter      send a keystroke (Enter, C-c, …)
    __BIN__ restart <id>         restart a wedged worker's command
  Prefer this over a SECOND worker when one already exists for the goal. NEVER
  use send/key to answer something a worker raised a ⚑flag for — those are for
  the HUMAN; leave the flag up and do nothing on it.
- change the PLAYBOOK: edit PLAYBOOK.md directly. The PLAYBOOK is the guardrail;
  your edit takes effect next tick. Be deliberate — only harden a drift into a
  rule once it actually hurts (RULE 5).
- do nothing (a valid move when nothing needs doing)

Optional — set WHEN the next beat runs: you may write a single integer (seconds)
to .next-interval (one-shot, clamped 5..3600). Use your judgment per the PLAYBOOK:
tighten to keep working when a backlog is piling up, or widen when it's been
quiet a long while (spare cost / external APIs). Write nothing to keep the
default rhythm. This does NOT count as your one move.

After your move, append exactly ONE line to journal.md. Copy the timestamp
prefix below VERBATIM — it is already in this host's local time (__TZ__).
Do NOT recompute it, do NOT convert to UTC, do NOT use your own clock:
  - __DATE_HM__ <what you did and why>
(For reference, the current local time right now is __NOW__.)

"#;

fn sorted_glob(dir: &Path, ext: &str) -> Vec<PathBuf> {
    let mut v: Vec<PathBuf> = fs::read_dir(dir)
        .into_iter()
        .flatten()
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().map(|e| e == ext).unwrap_or(false))
        .collect();
    v.sort();
    v
}

fn tail_lines(text: &str, n: usize) -> String {
    let lines: Vec<&str> = text.lines().collect();
    let start = lines.len().saturating_sub(n);
    lines[start..].join("\n")
}

pub fn build_prompt(paths: &Paths, snap_dir: &Path) -> String {
    let mut out = String::new();

    let instr = INSTRUCTIONS
        .replace("__DATA__", &paths.data_dir.to_string_lossy())
        .replace("__BIN__", &paths.bin.to_string_lossy())
        .replace("__TZ__", &util::date_fmt("%Z"))
        .replace("__DATE_HM__", &util::date_fmt("%Y-%m-%d %H:%M"))
        .replace("__NOW__", &util::date_fmt("%Y-%m-%d %H:%M %Z"));
    out.push_str(&instr);

    // PLAYBOOK.
    out.push_str("=== PLAYBOOK ===\n");
    out.push_str(&fs::read_to_string(paths.playbook()).unwrap_or_default());
    out.push('\n');

    // GOALS.
    out.push_str("\n=== GOALS ===\n");
    let goals = sorted_glob(&paths.goals_dir(), "md");
    if goals.is_empty() {
        out.push_str("(no goals yet)\n");
    } else {
        for g in goals {
            let name = g.file_name().unwrap_or_default().to_string_lossy();
            let _ = writeln!(out, "--- {name}");
            out.push_str(&fs::read_to_string(&g).unwrap_or_default());
            out.push('\n');
        }
    }

    // SENSOR READINGS.
    out.push_str("\n=== SENSOR READINGS ===\n");
    for o in sorted_glob(snap_dir, "json") {
        let fname = o
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();
        if !fname.starts_with("sensor-") {
            continue;
        }
        let _ = writeln!(out, "--- {fname}");
        out.push_str(&fs::read_to_string(&o).unwrap_or_default());
        out.push('\n');
    }

    // WORKER SESSIONS.
    out.push_str("\n=== WORKER SESSIONS (babysit; ⚑note = the worker is waiting for you) ===\n");
    let sessions = session::list_workers(paths);
    if sessions.is_empty() {
        out.push_str("(none)\n");
    } else {
        for s in &sessions {
            let exit = s
                .exit_code
                .map(|c| format!(" exit {c}"))
                .unwrap_or_default();
            let note = match &s.note {
                Some(n) => format!("  ⚑ {n}"),
                None => String::new(),
            };
            let _ = writeln!(out, "- {} [{}{}]{}", s.id, s.state, exit, note);
        }
    }

    // WORKER CLAIMS (live leases — reaped before this point, so all are live).
    out.push_str("\n=== WORKER CLAIMS (live leases — a name with a claim here is OWNED by a worker; do NOT act on it yourself, the owner is reconciling it) ===\n");
    let claims = sorted_glob(&paths.claims_dir(), "json");
    if claims.is_empty() {
        out.push_str("(none)\n");
    } else {
        for c in claims {
            let name = c
                .file_stem()
                .unwrap_or_default()
                .to_string_lossy()
                .to_string();
            let body = fs::read_to_string(&c).unwrap_or_default().replace('\n', "");
            let _ = writeln!(out, "- {name}: {body}");
        }
    }

    // RECENT JOURNAL.
    out.push_str("\n=== RECENT JOURNAL ===\n");
    match fs::read_to_string(paths.journal()) {
        Ok(j) if !j.is_empty() => {
            out.push_str(&tail_lines(&j, 20));
            out.push('\n');
        }
        _ => out.push_str("(empty)\n"),
    }

    out
}

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

    fn fixture() -> Paths {
        let p = Paths::temp();
        fs::create_dir_all(p.goals_dir()).unwrap();
        fs::create_dir_all(p.claims_dir()).unwrap();
        fs::write(p.playbook(), b"PB RULES\n").unwrap();
        fs::write(p.goals_dir().join("triage.md"), b"triage the inbox\n").unwrap();
        p
    }

    #[test]
    fn build_prompt_has_all_sections() {
        let p = fixture();
        let out = build_prompt(&p, &p.snapshots_dir());
        for marker in [
            "=== PLAYBOOK ===",
            "=== GOALS ===",
            "=== WORKER SESSIONS",
            "=== WORKER CLAIMS",
            "=== RECENT JOURNAL ===",
        ] {
            assert!(out.contains(marker), "missing section: {marker}");
        }
        assert!(out.contains("PB RULES"), "playbook body inlined");
        assert!(out.contains("triage the inbox"), "goal body inlined");
    }
}
