///|
// Capturable-output channel to the herdr CLI (the delegation transport).
// herdr writes result JSON on stdout and error JSON on stderr, so a backend
// must capture the two streams SEPARATELY — merged output would make a
// success read as an error. The trait below is the only seam; everything
// else in this file is pure and shared across targets.
priv trait HerdrCli {
  async fn run(self : Self, argv : Array[String]) -> (Int, String, String) raise @posoco.RuntimeError
}

///|
// One-shot headless always finishes turn 1 (cetas-headless lib/loop.mbt
// prints turn_marker(1)); parse_pane_text treats a line containing this as
// both the done flag and the success terminal.
let herdr_headless_marker : String = "[cetas-headless] turn 1 done"

///|
/// One Rust regex covering every terminal line the child (or its pane shell)
/// can print: the completion marker, the failure line, and the idle sentinel
/// the launcher echoes when the pane returns to a command-ready state.
let herdr_wait_regex : String = "\\[cetas-headless\\] (turn [0-9]+ done|turn failed:|pane-idle)"

///|
let herdr_turn_failed_prefix : String = "[cetas-headless] turn failed: "

///|
/// Idle sentinel: the pane shell echoes this after the child exits (the pane
/// is back at a command-ready state). The launcher types it split across a
/// quote boundary (`pane-id'le`) so the command-line echo never contains the
/// full literal — only the executed echo's output does; wait-output searches
/// existing pane output, which includes the typed echo.
let herdr_idle_sentinel : String = "[cetas-headless] pane-idle"

///|
let herdr_session_prefix : String = "session: "

///|
fn herdr_split_argv(
  bin : String,
  pane : String,
  cwd : String,
  direction : String,
) -> Array[String] {
  [
    bin, "pane", "split", "--pane", pane, "--direction", direction, "--cwd", cwd,
    "--no-focus",
  ]
}

///|
// command_line is ONE argv element: the pane's own shell parses its quoting.
fn herdr_run_argv(
  bin : String,
  new_pane : String,
  command_line : String,
) -> Array[String] {
  [bin, "pane", "run", new_pane, command_line]
}

///|
fn herdr_wait_argv(
  bin : String,
  new_pane : String,
  timeout_ms : Int,
) -> Array[String] {
  [
    bin,
    "pane",
    "wait-output",
    "--regex",
    herdr_wait_regex,
    // Same unwrapped source as `pane read`, so pty line-wrapping cannot break
    // the match; wait-output also matches pre-existing output first (the
    // command echo included), hence the quote-split sentinel below.
    "--source",
    "recent-unwrapped",
    "--timeout",
    timeout_ms.to_string(),
    new_pane,
  ]
}

///|
fn herdr_read_argv(bin : String, new_pane : String) -> Array[String] {
  [bin, "pane", "read", new_pane, "--source", "recent-unwrapped"]
}

///|
fn herdr_close_argv(bin : String, pane : String) -> Array[String] {
  [bin, "pane", "close", pane]
}

///|
/// New pane id from split's stdout JSON (`result.pane.pane_id`). An error
/// envelope (`{"error":{...}}`) carries no `result`, so it lands here as
/// None, as does unparseable output.
fn parse_split_pane_id(stdout : String) -> String? {
  let json : Json = @json.parse(stdout) catch { _ => return None }
  match json {
    Object(fields) =>
      match fields.get("result") {
        Some(Object(result)) =>
          match result.get("pane") {
            Some(Object(pane)) =>
              match pane.get("pane_id") {
                Some(String(id)) => Some(id)
                _ => None
              }
            _ => None
          }
        _ => None
      }
    _ => None
  }
}

///|
// Push `--flag value` only for a non-empty value: an empty string vanishes
// after the space join and lets the following `--` slide into the value slot.
fn herdr_push_flag(
  parts : Array[String],
  flag : String,
  value : String?,
) -> Unit {
  match value {
    Some(v) if v != "" => {
      parts.push(flag)
      parts.push(v)
    }
    _ => ()
  }
}

///|
/// Shell command line for `pane run`: the headless binary, permission flags
/// (readonly default, `--yolo` for write), optional model/effort/session
/// (empty strings are skipped), then `--` and the single-quoted task, and a
/// trailing idle-sentinel echo.
fn headless_command_line(
  headless_bin : String,
  permission : String,
  model : String?,
  effort : String?,
  session : String?,
  task : String,
) -> String {
  let parts : Array[String] = [headless_bin]
  if permission == "write" {
    parts.push("--yolo")
  } else {
    parts.push("--permission")
    parts.push("readonly")
  }
  herdr_push_flag(parts, "--model", model)
  herdr_push_flag(parts, "--effort", effort)
  herdr_push_flag(parts, "--session", session)
  parts.push("--")
  parts.push(herdr_shell_quote(task))
  // Idle sentinel for the wait regex: typed split across a quote boundary
  // (`pane-id'le`) so the command-line echo never contains the full literal —
  // only the executed echo's output does.
  parts.push("; echo '[cetas-headless] pane-id'le")
  parts.join(" ")
}

///|
priv struct HerdrPaneResult {
  answer : String
  child_session : String?
  failure : String?
  done : Bool
}

///|
/// Parse the merged pane screen (the pty folds stdout and stderr together).
/// Text-mode headless prints `session: ` first, then the answer, then
/// the completion marker; the failure line replaces the answer block. The
/// answer is everything after the session line (or the whole screen when no
/// session line appeared) up to the FIRST terminal line (marker, failure, or
/// the idle sentinel): the pane's shell re-draws a fresh prompt after the
/// child exits, so everything at or after the terminal line is prompt junk.
/// `done` is true when any line carried the completion marker.
fn parse_pane_text(text : String) -> HerdrPaneResult {
  let lines : Array[String] = []
  for line in text.split("\n") {
    lines.push(line.to_owned())
  }
  let mut child_session : String? = None
  let mut session_idx = -1
  let mut failure : String? = None
  let mut failure_idx = -1
  let mut done = false
  let mut terminal_idx = -1
  let mut i = 0
  while i < lines.length() {
    let line = lines[i]
    if child_session is None {
      match line.find(herdr_session_prefix) {
        Some(0) => {
          child_session = Some(line[herdr_session_prefix.length():].to_owned())
          session_idx = i
        }
        _ => ()
      }
    }
    if failure is None {
      match line.find(herdr_turn_failed_prefix) {
        Some(pos) => {
          failure = Some(
            line[pos + herdr_turn_failed_prefix.length():].to_owned(),
          )
          failure_idx = i
        }
        None => ()
      }
    }
    if line.contains(herdr_headless_marker) {
      done = true
    }
    if terminal_idx < 0 {
      if line.contains(herdr_headless_marker) ||
        line.contains(herdr_idle_sentinel) ||
        failure_idx == i {
        terminal_idx = i
      }
    }
    i += 1
  }
  let kept : Array[String] = []
  let start = if session_idx >= 0 { session_idx + 1 } else { 0 }
  let end = if terminal_idx >= 0 { terminal_idx } else { lines.length() }
  let mut j = start
  while j < end {
    kept.push(lines[j])
    j += 1
  }
  { answer: kept.join("\n").trim().to_owned(), child_session, failure, done, }
}

///|
/// `wait-output` timeout shape: exit 1 with an error envelope whose code is
/// "timeout" on stderr.
fn is_timeout_error(exit : Int, stderr : String) -> Bool {
  exit == 1 && stderr.contains("\"code\":\"timeout\"")
}

///|
/// Bounded excerpt for model-visible errors; herdr's stderr JSON envelopes
/// can be long and the model only needs the head of them.
fn herdr_excerpt(text : String) -> String {
  let trimmed = text.trim().to_owned()
  if trimmed.length() > 200 {
    trimmed[:200].to_owned() + "..."
  } else {
    trimmed
  }
}

///|
/// Single-quote an argument for the shell command line built by `pane run`.
/// `'` inside the value is closed, escaped, and reopened.
fn herdr_shell_quote(s : String) -> String {
  "'" + s.replace(old="'", new="'\\''") + "'"
}