///|
/// What the shim has learned from `claude -p --output-format stream-json`
/// so far. Purely structural: only the fields named below are read, and
/// every other line or field is ignored, so an added field upstream
/// changes nothing here.
///
/// The stream emits one `assistant` line PER CONTENT BLOCK (thinking,
/// text, tool_use) of the same message, each carrying that message's
/// running usage snapshot; the final `result` carries the run's true
/// totals (its `output_tokens` include thinking). So a step is a DISTINCT
/// message id, cost is charged once from the result, and the per-message
/// snapshots serve only as the fallback for a run cut short before its
/// result (`usage`).
priv struct Claude {
  mut steps : Int
  snapshots : Map[String, @shim.Usage]
  /// The result's true totals, priced when it stated a price.
  mut charged : @shim.Usage?
  mut answer : Json?
  mut session_id : String?
  mut num_turns : Int?
  mut cost_usd : Double?
  mut failure : String?
  mut exhausted : Bool
}

///|
/// A fresh observer.
fn Claude::Claude() -> Claude {
  {
    steps: 0,
    snapshots: {},
    charged: None,
    answer: None,
    session_id: None,
    num_turns: None,
    cost_usd: None,
    failure: None,
    exhausted: false,
  }
}

///|
/// Classify one stdout line into the state.
fn Claude::observe(self : Claude, line : String) -> Unit {
  let trimmed = line.trim(chars="\r\n")
  if trimmed == "" {
    return
  }
  let json = @json.parse(trimmed) catch { _ => return }
  match json {
    { "type": "assistant", "message": message, .. } => {
      // An id-less message (never seen from the CLI, but not a reason to
      // miscount) is its own step.
      let id = if message is { "id": String(id), .. } {
        id
      } else {
        "anonymous-\{self.steps + 1}"
      }
      let first_seen = !self.snapshots.contains(id)
      if first_seen {
        self.steps += 1
      }
      match cost_of(message) {
        Some(cost) => self.snapshots[id] = cost
        None =>
          if first_seen {
            self.snapshots[id] = @shim.Usage(
              prompt_tokens=0,
              completion_tokens=0,
              cache_hit_tokens=0,
              cache_miss_tokens=0,
            )
          }
      }
    }
    { "type": "result", .. } => {
      if json is { "session_id": String(id), .. } {
        self.session_id = Some(id)
      }
      if json is { "num_turns": Number(turns, ..), .. } {
        self.num_turns = @shim.integral(turns)
      }
      if json is { "total_cost_usd": Number(cost, ..), .. } {
        self.cost_usd = Some(cost)
      }
      if cost_of(json) is Some(cost) {
        self.charged = Some(
          @shim.Usage(
            prompt_tokens=cost.prompt_tokens,
            completion_tokens=cost.completion_tokens,
            cache_hit_tokens=cost.cache_hit_tokens,
            cache_miss_tokens=cost.cache_miss_tokens,
            cost_usd?=self.cost_usd,
          ),
        )
      }
      let is_error = json is { "is_error": True, .. }
      let subtype = if json is { "subtype": String(subtype), .. } {
        subtype
      } else {
        "success"
      }
      let text = if json is { "result": String(text), .. } { text } else { "" }
      if subtype == "error_max_turns" {
        self.exhausted = true
      } else if is_error || subtype != "success" {
        self.failure = Some(
          if text == "" {
            "claude: \{subtype}"
          } else {
            "claude: \{subtype}: \{text}"
          },
        )
      } else if json is { "structured_output": structured, .. } &&
        !(structured is Null) {
        self.answer = Some(structured)
      } else {
        self.answer = Some(Json(text))
      }
    }
    _ => ()
  }
}

///|
/// The `usage` object Claude attaches to a message or to the result,
/// mapped onto the contract's counters: cache reads are hits; fresh input
/// and cache creation are misses; prompt tokens are all three together.
fn cost_of(container : Json) -> @shim.Usage? {
  guard container
    is {
      "usage": {
        "input_tokens": Number(input, ..),
        "output_tokens": Number(output, ..),
        ..
      } as usage,
      ..
    } else {
    return None
  }
  guard @shim.integral(input) is Some(input) &&
    @shim.integral(output) is Some(output) else {
    return None
  }
  let cache_read = if usage
    is { "cache_read_input_tokens": Number(read, ..), .. } {
    @shim.integral(read).unwrap_or(0)
  } else {
    0
  }
  let cache_creation = if usage
    is { "cache_creation_input_tokens": Number(created, ..), .. } {
    @shim.integral(created).unwrap_or(0)
  } else {
    0
  }
  Some(
    @shim.Usage(
      prompt_tokens=input + cache_read + cache_creation,
      completion_tokens=output,
      cache_hit_tokens=cache_read,
      cache_miss_tokens=input + cache_creation,
    ),
  )
}

///|
/// What the run cost: the result's true totals when it arrived; else, for a
/// run cut short before its result (a step ceiling, a cancel, a crash), the
/// sum of the last snapshot of every message seen, unpriced. `None` when
/// nothing was observed — unknown, not free.
fn Claude::usage(self : Claude) -> @shim.Usage? {
  if self.charged is Some(total) {
    return Some(total)
  }
  let mut total : @shim.Usage? = None
  for _, cost in self.snapshots {
    total = Some(
      match total {
        Some(sum) => sum.add(cost)
        None => cost
      },
    )
  }
  total
}

///|
/// How the run ended, given how the CLI did. A result the CLI itself
/// classified (a failure, its own turn cap) wins over the exit status; a
/// nonzero exit without any result is a failure, a clean one `no_report`.
fn Claude::status(self : Claude, exit : @shim.CliExit) -> @shim.Status {
  match exit {
    Cancelled => Interrupted("the parent closed stdin")
    Stopped => MaxStepsExhausted
    Failed(reason) => Failed(reason)
    Exited(code) =>
      if self.failure is Some(error) {
        Failed(error)
      } else if self.exhausted {
        MaxStepsExhausted
      } else if self.report() is Some(report) {
        Completed(report)
      } else if code != 0 {
        Failed("claude exited \{code} without a result")
      } else {
        NoReport
      }
  }
}

///|
/// The result file's content for a run that ended with `exit`.
fn Claude::result(self : Claude, exit : @shim.CliExit) -> @shim.ShimResult {
  @shim.ShimResult(self.status(exit), usage?=self.usage(), steps=self.steps)
}

///|
/// The report, once a successful result arrived: the answer plus what a
/// caller may want for resume or accounting.
fn Claude::report(self : Claude) -> Json? {
  guard self.answer is Some(answer) else { return None }
  let fields : Map[String, Json] = { "answer": answer, "engine": "claude" }
  if self.session_id is Some(id) {
    fields["session_id"] = Json(id)
  }
  if self.num_turns is Some(turns) {
    fields["num_turns"] = Json(turns)
  }
  if self.cost_usd is Some(cost) {
    fields["cost_usd"] = Json(cost)
  }
  Some(Json::object(fields))
}

///|
/// Feed `lines` to a fresh observer.
fn observed(lines : Array[String]) -> Claude {
  let state = Claude()
  for line in lines {
    state.observe(line)
  }
  state
}

///|
/// Lines recorded from claude 2.1.258 (`--output-format stream-json
/// --verbose`, Haiku 4.5, prompt "Reply with exactly the single word:
/// ok"), trimmed to the fields that matter. Both `assistant` lines are
/// the SAME message (thinking block, then text block).
test "one message across two content blocks is one step; the result charges" {
  let state = observed([
    (
      #|{"type":"system","subtype":"init","session_id":"s1","uuid":"u0"}
    ),
    (
      #|{"type":"rate_limit_event","uuid":"u1"}
    ),
    (
      #|{"type":"system","subtype":"thinking_tokens","uuid":"u2"}
    ),
    (
      #|{"type":"assistant","message":{"id":"msg_1","content":[{"type":"thinking"}],"usage":{"input_tokens":10,"cache_creation_input_tokens":7669,"cache_read_input_tokens":13615,"output_tokens":4}},"uuid":"u3"}
    ),
    (
      #|{"type":"assistant","message":{"id":"msg_1","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":10,"cache_creation_input_tokens":7669,"cache_read_input_tokens":13615,"output_tokens":4}},"uuid":"u4"}
    ),
    (
      #|{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s1","num_turns":1,"total_cost_usd":0.0173444,"usage":{"input_tokens":10,"cache_creation_input_tokens":7669,"cache_read_input_tokens":13615,"output_tokens":43,"output_tokens_details":{"thinking_tokens":37}}}
    ),
  ])
  assert_eq(state.result(Exited(0)).to_json(request_id="r"), {
    "version": 1,
    "request_id": "r",
    "status": "completed",
    "output": {
      "answer": "ok",
      "engine": "claude",
      "session_id": "s1",
      "num_turns": 1,
      "cost_usd": 0.0173444,
    },
    // The result's totals, not the per-message snapshots.
    "usage": {
      "prompt_tokens": 21294,
      "completion_tokens": 43,
      "total_tokens": 21337,
      "prompt_cache_hit_tokens": 13615,
      "prompt_cache_miss_tokens": 7679,
      "cost_usd": 0.0173444,
    },
    "steps": 1,
  })
}

///|
test "a run cut short settles the last snapshot of every message seen" {
  let state = observed([
    (
      #|{"type":"assistant","message":{"id":"msg_1","usage":{"input_tokens":10,"cache_read_input_tokens":90,"output_tokens":2}}}
    ),
    (
      #|{"type":"assistant","message":{"id":"msg_1","usage":{"input_tokens":10,"cache_read_input_tokens":90,"output_tokens":5}}}
    ),
    (
      #|{"type":"assistant","message":{"id":"msg_2","usage":{"input_tokens":3,"output_tokens":7}}}
    ),
  ])
  // msg_1's LAST snapshot (5 output tokens) plus msg_2's, unpriced.
  let usage : Json = {
    "prompt_tokens": 103,
    "completion_tokens": 12,
    "total_tokens": 115,
    "prompt_cache_hit_tokens": 90,
    "prompt_cache_miss_tokens": 13,
  }
  assert_eq(state.result(Stopped).to_json(request_id="r"), {
    "version": 1,
    "request_id": "r",
    "status": "max_steps_exhausted",
    "usage": usage,
    "steps": 2,
  })
  assert_eq(state.result(Cancelled).to_json(request_id="r"), {
    "version": 1,
    "request_id": "r",
    "status": "interrupted",
    "reason": "the parent closed stdin",
    "usage": usage,
    "steps": 2,
  })
  assert_eq(state.result(Exited(137)).to_json(request_id="r"), {
    "version": 1,
    "request_id": "r",
    "status": "failed",
    "reason": "claude exited 137 without a result",
    "usage": usage,
    "steps": 2,
  })
}

///|
test "nothing observed is unknown usage, never zero" {
  assert_eq(observed([]).result(Exited(0)).to_json(request_id="r"), {
    "version": 1,
    "request_id": "r",
    "status": "no_report",
    "steps": 0,
  })
  assert_eq(
    observed([])
    .result(Failed("cannot spawn claude: ENOENT"))
    .to_json(request_id="r"),
    {
      "version": 1,
      "request_id": "r",
      "status": "failed",
      "reason": "cannot spawn claude: ENOENT",
      "steps": 0,
    },
  )
}

///|
test "error results and the turn ceiling produce no report" {
  let failed = observed([
    (
      #|{"type":"result","subtype":"error_during_execution","is_error":true,"result":"boom"}
    ),
  ])
  assert_eq(
    failed.status(Exited(1)),
    Failed("claude: error_during_execution: boom"),
  )
  let capped = observed([
    (
      #|{"type":"result","subtype":"error_max_turns","is_error":true}
    ),
  ])
  assert_eq(capped.status(Exited(0)), MaxStepsExhausted)
  assert_eq(capped.report(), None)
}

///|
test "a structured answer replaces the text" {
  let state = observed([
    (
      #|{"type":"result","subtype":"success","result":"","structured_output":{"verdict":"real"}}
    ),
  ])
  assert_eq(
    state.report(),
    Some({ "answer": { "verdict": "real" }, "engine": "claude" }),
  )
}

///|
// Synthetic framing and partial-message cases, not a new CLI dialect.
test "missing usage blocks preserve the last valid snapshot and one step" {
  let missing = "{\"type\":\"assistant\",\"message\":{\"id\":\"m\"}}\r\n"
  let state = observed([
    "", "\r\n", "not json", missing, "{\"type\":\"assistant\",\"message\":{\"id\":\"m\",\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}",
    missing,
  ])
  assert_eq(state.steps, 1)
  assert_eq(state.result(Exited(0)).to_json(request_id="r"), {
    "version": 1,
    "request_id": "r",
    "status": "no_report",
    "usage": {
      "prompt_tokens": 3,
      "completion_tokens": 2,
      "total_tokens": 5,
      "prompt_cache_hit_tokens": 0,
      "prompt_cache_miss_tokens": 3,
    },
    "steps": 1,
  })
  state.observe(
    "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"你好🌙\"}\r\n",
  )
  assert_eq(
    state.report(),
    Some({ "answer": "你好🌙", "engine": "claude" }),
  )
}