///|
/// The tracked execution's state. Interrupted means its owner disappeared;
/// it does not assert that every descendant process was terminated.
pub(all) enum JobState {
  Running
  Stopping
  Exited(Int)
  Stopped(String)
  Interrupted
} derive(Eq, Debug)

///|
pub(all) enum JobEventKind {
  Started
  Updated
  Finished
  Failed
} derive(Eq, Debug)

///|
pub(all) enum JobStopOutcome {
  Stopped
  AlreadyTerminal
  NotFound
  WrongRuntime
} derive(Eq, Debug)

///|
/// A durable background execution record. output_file is a basename relative
/// to jobs/; clients resolve its current host path via the store.
pub(all) struct JobRecord {
  generation : String
  id : String
  revision : Int
  command : String
  description : String?
  cwd : String?
  state : JobState
  started_at_ms : Int64
  backgrounded_at_ms : Int64
  finished_at_ms : Int64?
  last_output_at_ms : Int64?
  output_file : String?
  output_persistent : Bool
  output_bytes : Int64
  output_chars : Int
  output_truncated : Bool
  invalid_utf8 : Bool
  output_error : String?
  persistence_error : String?
  cleanup_error : String?
} derive(Eq, Debug)

///|
pub fn JobState::is_terminal(self : JobState) -> Bool {
  match self {
    Running | Stopping => false
    _ => true
  }
}

///|
pub impl ToJson for JobState with fn to_json(self) {
  match self {
    Running => { "kind": "running" }
    Stopping => { "kind": "stopping" }
    Exited(code) => { "kind": "exited", "code": code }
    Stopped(reason) => { "kind": "stopped", "reason": reason }
    Interrupted => { "kind": "interrupted" }
  }
}

///|
pub impl FromJson for JobState with fn from_json(json, path) {
  match json {
    { "kind": "running", .. } => Running
    { "kind": "stopping", .. } => Stopping
    { "kind": "interrupted", .. } => Interrupted
    { "kind": "stopped", "reason": String(reason), .. } if !reason.is_empty() =>
      Stopped(reason)
    { "kind": "exited", "code": Number(code, ..), .. } if code >= -2147483648.0 &&
      code <= 2147483647.0 &&
      code.to_int().to_double() == code => Exited(code.to_int())
    _ => raise JsonDecodeError((path, "invalid background job state"))
  }
}

///|
fn job_field(
  fields : Map[String, Json],
  name : String,
  path : @json.JsonPath,
) -> Json raise @json.JsonDecodeError {
  match fields.get(name) {
    Some(value) => value
    None => raise JsonDecodeError((path, "missing job field: " + name))
  }
}

///|
fn[T : FromJson] job_optional(
  fields : Map[String, Json],
  name : String,
  path : @json.JsonPath,
) -> T? raise @json.JsonDecodeError {
  match fields.get(name) {
    None | Some(Null) => None
    Some(value) => Some(T::from_json(value, path))
  }
}

///|
fn[T : ToJson] job_optional_json(value : T?) -> Json {
  match value {
    Some(value) => value.to_json()
    None => Json::null()
  }
}

///|
fn job_uint(
  fields : Map[String, Json],
  name : String,
  path : @json.JsonPath,
) -> Int64 raise @json.JsonDecodeError {
  match job_field(fields, name, path) {
    Number(value, ..) if value >= 0 &&
      value <= 9007199254740991.0 &&
      value.to_int64().to_double() == value => value.to_int64()
    _ =>
      raise JsonDecodeError((path, "invalid nonnegative job integer: " + name))
  }
}

///|
fn job_optional_uint(
  fields : Map[String, Json],
  name : String,
  path : @json.JsonPath,
) -> Int64? raise @json.JsonDecodeError {
  match fields.get(name) {
    None | Some(Null) => None
    Some(_) => Some(job_uint(fields, name, path))
  }
}

///|
/// An artifact component, never a path supplied by output text.
pub fn valid_job_component(value : String) -> Bool {
  !value.is_empty() &&
  value != "." &&
  value != ".." &&
  value.length() <= 200 &&
  value
  .iter()
  .all(c => {
    c is ('a'..='z' | 'A'..='Z' | '0'..='9') || c == '-' || c == '_' || c == '.'
  })
}

///|
pub impl ToJson for JobRecord with fn to_json(self) {
  {
    "schema_version": 1,
    "cleanup_error": job_optional_json(self.cleanup_error),
    "generation": self.generation.to_json(),
    "id": self.id.to_json(),
    "revision": self.revision.to_json(),
    "command": self.command.to_json(),
    "description": job_optional_json(self.description),
    "cwd": job_optional_json(self.cwd),
    "state": self.state.to_json(),
    "started_at_ms": self.started_at_ms.to_double().to_json(),
    "backgrounded_at_ms": self.backgrounded_at_ms.to_double().to_json(),
    "finished_at_ms": job_optional_json(
      self.finished_at_ms.map(value => value.to_double()),
    ),
    "last_output_at_ms": job_optional_json(
      self.last_output_at_ms.map(value => value.to_double()),
    ),
    "output_file": job_optional_json(self.output_file),
    "output_persistent": self.output_persistent.to_json(),
    "output_bytes": self.output_bytes.to_double().to_json(),
    "output_chars": self.output_chars.to_json(),
    "output_truncated": self.output_truncated.to_json(),
    "invalid_utf8": self.invalid_utf8.to_json(),
    "output_error": job_optional_json(self.output_error),
    "persistence_error": job_optional_json(self.persistence_error),
  }
}

///|
pub impl FromJson for JobRecord with fn from_json(json, path) {
  guard json is Object(fields) &&
    fields.get("schema_version") is Some(Number(1, ..)) else {
    raise JsonDecodeError((path, "expected background job schema version 1"))
  }
  let cleanup_error : String? = job_optional(fields, "cleanup_error", path)
  let generation : String = FromJson::from_json(
    job_field(fields, "generation", path),
    path,
  )
  let id : String = FromJson::from_json(job_field(fields, "id", path), path)
  let revision = job_uint(fields, "revision", path)
  let command : String = FromJson::from_json(
    job_field(fields, "command", path),
    path,
  )
  let description : String? = job_optional(fields, "description", path)
  let cwd : String? = job_optional(fields, "cwd", path)
  let state : JobState = FromJson::from_json(
    job_field(fields, "state", path),
    path,
  )
  let started_at_ms = job_uint(fields, "started_at_ms", path)
  let backgrounded_at_ms = job_uint(fields, "backgrounded_at_ms", path)
  let finished_at_ms : Int64? = job_optional_uint(
    fields, "finished_at_ms", path,
  )
  let last_output_at_ms : Int64? = job_optional_uint(
    fields, "last_output_at_ms", path,
  )
  let output_file : String? = job_optional(fields, "output_file", path)
  let output_persistent : Bool = FromJson::from_json(
    job_field(fields, "output_persistent", path),
    path,
  )
  let output_bytes = job_uint(fields, "output_bytes", path)
  let output_chars = job_uint(fields, "output_chars", path)
  let output_truncated : Bool = FromJson::from_json(
    job_field(fields, "output_truncated", path),
    path,
  )
  let invalid_utf8 : Bool = FromJson::from_json(
    job_field(fields, "invalid_utf8", path),
    path,
  )
  let output_error : String? = job_optional(fields, "output_error", path)
  let persistence_error : String? = job_optional(
    fields, "persistence_error", path,
  )
  guard valid_job_component(generation) &&
    valid_job_component(id) &&
    revision > 0 &&
    revision <= 2147483647L &&
    output_chars <= 2147483647L &&
    (match output_file {
      Some(file) => valid_job_component(file)
      None => true
    }) else {
    raise JsonDecodeError((path, "invalid background job identity or count"))
  }
  {
    generation,
    cleanup_error,
    id,
    revision: revision.to_int(),
    command,
    description,
    cwd,
    state,
    started_at_ms,
    backgrounded_at_ms,
    finished_at_ms,
    last_output_at_ms,
    output_file,
    output_persistent,
    output_bytes,
    output_chars: output_chars.to_int(),
    output_truncated,
    invalid_utf8,
    output_error,
    persistence_error,
  }
}

///|
pub extend JobState with Eq::{equal, not_equal}

///|
pub extend JobState with Debug::{to_repr}

///|
pub extend JobRecord with Eq::{equal, not_equal}

///|
pub extend JobRecord with Debug::{to_repr}

///|
pub extend JobEventKind with Eq::{equal, not_equal}

///|
pub extend JobEventKind with Debug::{to_repr}

///|
pub extend JobStopOutcome with Eq::{equal, not_equal}

///|
pub extend JobStopOutcome with Debug::{to_repr}

///|
pub impl ToJson for JobEventKind with fn to_json(self) {
  match self {
    Started => "started".to_json()
    Updated => "updated".to_json()
    Finished => "finished".to_json()
    Failed => "failed".to_json()
  }
}

///|
pub impl FromJson for JobEventKind with fn from_json(json, path) {
  match json {
    String("started") => Started
    String("updated") => Updated
    String("finished") => Finished
    String("failed") => Failed
    _ => raise JsonDecodeError((path, "invalid JobEventKind"))
  }
}

///|
pub impl ToJson for JobStopOutcome with fn to_json(self) {
  match self {
    Stopped => "stopped".to_json()
    AlreadyTerminal => "already_terminal".to_json()
    NotFound => "not_found".to_json()
    WrongRuntime => "wrong_runtime".to_json()
  }
}

///|
pub impl FromJson for JobStopOutcome with fn from_json(json, path) {
  match json {
    String("stopped") => Stopped
    String("already_terminal") => AlreadyTerminal
    String("not_found") => NotFound
    String("wrong_runtime") => WrongRuntime
    _ => raise JsonDecodeError((path, "invalid JobStopOutcome"))
  }
}

///|
pub extend JobRecord with ToJson::{to_json}

///|
pub extend JobState with ToJson::{to_json}

///|
pub extend JobEventKind with ToJson::{to_json}

///|
pub extend JobStopOutcome with ToJson::{to_json}

///|
pub extend JobState with FromJson::{from_json}

///|
pub extend JobRecord with FromJson::{from_json}

///|
pub extend JobEventKind with FromJson::{from_json}

///|
pub extend JobStopOutcome with FromJson::{from_json}