///|
/// Invalid schema values are separate from malformed JSON syntax.
pub(all) suberror DecodeError {
  DecodeError(String)
}

///|
fn required_string(
  obj : Map[String, Json],
  key : String,
) -> String raise DecodeError {
  match obj.get(key) {
    Some(String(value)) if value.trim().length() > 0 => value
    _ => raise DecodeError("expected nonempty string: " + key)
  }
}

///|
/// Restrict counters to JSON's interoperable exact integer range.
fn optional_tokens(
  obj : Map[String, Json],
  key : String,
) -> Int64? raise DecodeError {
  match obj.get(key) {
    None | Some(Null) => None
    Some(Number(_, repr=Some(raw))) => {
      for char in raw {
        if char < '0' || char > '9' {
          raise DecodeError("expected unsigned decimal integer: " + key)
        }
      }
      let integer = @string.parse_int64(raw) catch {
        _ => raise DecodeError("token count out of range: " + key)
      }
      if integer > 9007199254740991L {
        raise DecodeError("token count out of range: " + key)
      }
      Some(integer)
    }
    Some(Number(value, repr=None)) if value >= 0.0 &&
      value <= 9007199254740991.0 => {
      let integer = value.to_int64()
      if integer.to_double() != value {
        raise DecodeError("expected integer token count: " + key)
      }
      Some(integer)
    }
    _ => raise DecodeError("token count out of range or wrong type: " + key)
  }
}

///|
/// Decode one normalized request attempt; unknown extra fields are ignored.
pub fn decode_call(value : Json) -> Call raise DecodeError {
  guard value is Object(obj) else { raise DecodeError("expected object") }
  let status = required_string(obj, "status")
  if status != "ok" && status != "error" {
    raise DecodeError("status must be ok or error")
  }
  {
    run_id: required_string(obj, "run_id"),
    task_id: required_string(obj, "task_id"),
    stage: required_string(obj, "stage"),
    call_id: required_string(obj, "call_id"),
    model: required_string(obj, "model"),
    status,
    input_tokens: optional_tokens(obj, "input_tokens"),
    output_tokens: optional_tokens(obj, "output_tokens"),
  }
}

///|
/// Inspect all nonblank lines, retaining physical line numbers for diagnostics.
pub fn parse_jsonl(text : String) -> Parsed {
  let records : Array[Record] = []
  let findings : Array[Finding] = []
  for index, line in text.split("\n").to_array() {
    if line.trim().is_empty() {
      continue
    }
    let value = @json.parse(
      line,
      reject_duplicate_keys=true,
      preserve_number_repr=true,
    ) catch {
      _ => {
        findings.push({
          line: index + 1,
          code: "invalid_json",
          message: "malformed JSON or duplicate object keys",
        })
        continue
      }
    }
    let call = decode_call(value) catch {
      DecodeError(message) => {
        findings.push({ line: index + 1, code: "invalid_record", message, })
        continue
      }
    }
    records.push({ line: index + 1, call, })
  }
  { records, findings, }
}