///|
/// THE SHIM SIDE OF THE CHILD CONTRACT — what every process shim around a
/// foreign agent CLI needs (one request line in, one result file out):
/// `serve` parses the shim's argv and the one request line the runner
/// writes, and writes the one result file; `run_cli` runs the CLI while
/// the parent's stdin-EOF cancel signal stays live. `shim/claude` and
/// `shim/codex` are the two dialects; this package knows no CLI.
///
/// What the observer decides after each CLI stdout line.
pub(all) enum Verdict {
  Continue
  Stop
} derive(Eq)

///|
/// How a CLI run ended, from the shim's side. `Cancelled` is the parent's
/// stdin EOF: the run is `interrupted`, and the parent is already winding
/// down. `Stopped` is the observer's own decision (a step ceiling).
/// `Failed` is a run that never got going — an unspawnable command, a
/// broken pipe.
pub(all) enum CliExit {
  Exited(Int)
  Stopped
  Cancelled
  Failed(String)
} derive(Eq)

///|
/// Run the CLI: spawn it with its stdin already at EOF (the prompt rides
/// argv; OUR stdin is the parent's cancel channel and is never the
/// CLI's), hand every stdout line to `observe`, and tear the CLI down when
/// the observer says `Stop` or the parent closes our stdin. stderr is
/// inherited, so the CLI's diagnostics reach the parent's stderr
/// untouched.
///
/// A `Stop`, and the parent's cancel, are GRACEFUL: the CLI gets
/// `stop_signal` (SIGINT, its own Ctrl-C path) and `stop_grace_ms` to
/// flush, and every line it prints meanwhile still reaches `observe` — a
/// CLI that reports usage only at the end of a turn can still get its cost
/// recorded on a capped or cancelled run. Then it is killed. Every error
/// path — an observer that raises, a broken pipe, the caller's
/// cancellation — closes the pipes and terminates the CLI before returning
/// or re-raising; the task group would reap it anyway, but not before the
/// caller had already been told.
///
/// `until_cancelled` returns when the parent cancels the run; by default
/// that is our stdin reaching EOF, the contract's cancel signal. A test,
/// whose stdin is not a parent's pipe, passes its own.
pub async fn run_cli(
  command~ : String,
  args~ : Array[String],
  cwd? : String,
  until_cancelled? : async () -> Unit = parent_closes_stdin,
  stop_grace_ms? : Int = 3_000,
  stop_signal? : @signal.Signal = @signal.SIGINT,
  observe~ : async (String) -> Verdict,
) -> CliExit {
  let cancelled : Ref[Bool] = { val: false, }
  let stopped : Ref[Bool] = { val: false, }
  let code : Ref[Int] = { val: 0, }
  let failure : Ref[String?] = { val: None, }
  try
    @async.with_task_group() <| group => {
      let (child_stdin, stdin_writer) = @process.write_to_process() catch {
        error => {
          failure.val = Some("shim pipes unavailable: \{error}")
          return
        }
      }
      // Cleanup lives in `defer`, not in `catch` arms: cancellation is a
      // signal that bypasses `catch` and unwinds only through `defer` /
      // `errdefer`. Closing a pipe and cancelling a process are idempotent,
      // so the deferred teardown also covers the explicit closes below.
      defer stdin_writer.close()
      let (stdout_reader, child_stdout) = @process.read_from_process() catch {
        error => {
          failure.val = Some("shim pipes unavailable: \{error}")
          return
        }
      }
      defer stdout_reader.close()
      let process = @process.spawn(
        group,
        command,
        args,
        inherit_env=true,
        stdin=child_stdin,
        stdout=child_stdout,
        cwd?=cwd.map(dir => dir.view()),
        // `cancel()` sends SIGINT first — the CLI's own Ctrl-C path, the
        // one it flushes on — and kills only after the grace.
        cancel_handler=@process.graceful_cancel(
          timeout=stop_grace_ms,
          signal=stop_signal,
        ),
        no_wait=true,
      ) catch {
        error => {
          failure.val = Some("cannot spawn \{command}: \{error}")
          return
        }
      }
      defer process.cancel()
      // EOF at once: the CLI must never wait on stdin for a prompt.
      stdin_writer.close()
      group.spawn_bg(no_wait=true, allow_failure=true) <| () => {
        until_cancelled()
        cancelled.val = true
        process.cancel()
      }
      try {
        while stdout_reader.read_until("\n") is Some(line) {
          if observe(line) is Stop && !cancelled.val {
            stopped.val = true
          }
          if cancelled.val || stopped.val {
            break
          }
        }
        if cancelled.val || stopped.val {
          // Graceful: SIGINT now (again, after a cancel: idempotent), keep
          // listening through the grace so a final accounting line still
          // counts, then the handler kills.
          process.cancel()
          let _ = @async.with_timeout_opt(stop_grace_ms, () => {
            while stdout_reader.read_until("\n") is Some(late) {
              let _ = observe(late)
            }
          })
        } else {
          // Bounded: a CLI that closed stdout but lingers is torn down below.
          code.val = @async.with_timeout_opt(2_000, () => process.wait()).unwrap_or(
            -1,
          )
        }
      } catch {
        error => {
          if failure.val is None {
            failure.val = Some("shim run failed: \{error}")
          }
          return
        }
      }
    }
  catch {
    error =>
      if failure.val is None {
        failure.val = Some("shim run failed: \{error}")
      }
  }
  match failure.val {
    Some(reason) => Failed(reason)
    None if cancelled.val => Cancelled
    None if stopped.val => Stopped
    None => Exited(code.val)
  }
}

///|
/// The contract's cancel signal: the parent closing our stdin. The request
/// line is already read; only EOF is meaningful after it.
async fn parent_closes_stdin() -> Unit {
  while @stdio.stdin.read_until("\n") is Some(_) {

  }
}