///|
/// A new producer report identity for one failing environment/shard group.
pub(all) struct RetryReport {
  id : String
  environment : String
  shard : String
} derive(Debug, Eq)

///|
pub extend RetryReport with @debug.Debug::{to_repr}

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

///|
pub fn decode_retry_reports(
  source : String,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> Array[RetryReport] raise @ledger.InputError {
  let fields = object(strict_json(source, limits), "$")
  keys(fields, ["schema", "reports"], "$")
  schema(fields)
  let items = array(required(fields, "reports", "$"), "$/reports")
  if items.length() > limits.max_reports {
    raise bad("ReportLimit", "$/reports", "Retry report budget exceeded")
  }
  items.mapi((index, value) => {
    let path = "$/reports/\{index}"
    let item = object(value, path)
    keys(item, ["id", "environment", "shard"], path)
    {
      id: nonempty(string_field(item, "id", path), path + "/id"),
      environment: nonempty(
        string_field(item, "environment", path),
        path + "/environment",
      ),
      shard: nonempty(string_field(item, "shard", path), path + "/shard"),
    }
  })
}

///|
pub fn encode_retry_reports(reports : ArrayView[RetryReport]) -> String {
  Json::object({
    "schema": Json::number(1),
    "reports": Json::array(
      reports
      .to_owned()
      .map(report => {
        Json::object({
          "id": Json::string(report.id),
          "environment": Json::string(report.environment),
          "shard": Json::string(report.shard),
        })
      }),
    ),
  }).stringify(indent=2)
}

///|
/// Extend a complete run by exactly one selective retry for every group with a
/// final failed/error/unresolved case. All existing inventory/source/policy stays.
/// The returned archive is incomplete until the newly declared reports arrive.
pub fn prepare_retry(
  evidence : Evidence,
  reports : ArrayView[RetryReport],
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> Evidence raise @ledger.InputError {
  limits.validate()
  let summary = evidence.summary()
  if !summary.complete {
    raise bad(
      "RetryIncomplete", "/plan", "Fill existing missing evidence before declaring another retry",
    )
  }
  if reports.length() > limits.max_reports {
    raise bad("ReportLimit", "/reports", "Retry report budget exceeded")
  }
  let groups : Map[(String, String), Array[@ledger.CaseKey]] = Map([])
  for item in summary.cases {
    match item.verdict {
      Failed | Error | Unresolved => {
        let group = (item.environment, item.shard)
        let cases = groups.get_or_init(group, () => [])
        cases.push(item.key)
      }
      _ => ()
    }
  }
  if groups.is_empty() {
    raise bad(
      "NoRetryCases", "/cases", "No final failed, error or unresolved tests need a retry; recovered tests are not rescheduled",
    )
  }
  let snapshot = evidence.snapshot()
  let expected = snapshot.plan.reports.copy()
  let supplied : Set[(String, String)] = Set([])
  for index, report in reports {
    let path = "/reports/\{index}"
    let group = (report.environment, report.shard)
    if supplied.contains(group) {
      raise bad(
        "DuplicateRetryGroup", path, "Only one new report may be declared per environment/shard",
      )
    }
    supplied.add(group)
    guard groups.get(group) is Some(cases) else {
      raise bad(
        "UnexpectedRetryGroup", path, "This environment/shard has no final adverse tests",
      )
    }
    let mut last = 0
    for previous in snapshot.plan.reports {
      if previous.environment == report.environment &&
        previous.shard == report.shard {
        last = Int32::max(last, previous.attempt)
      }
    }
    if last == 2147483647 {
      raise bad(
        "RetryAttemptLimit", path, "Retry attempt ordinal cannot exceed a positive 32-bit integer",
      )
    }
    expected.push({
      id: report.id,
      environment: report.environment,
      shard: report.shard,
      attempt: last + 1,
      cases: cases.copy(),
    })
  }
  if supplied.length() != groups.length() {
    raise bad(
      "MissingRetryGroup", "/reports", "Supply a new report identity for every environment/shard with final adverse tests",
    )
  }
  let plan = @ledger.RunPlan::new(
    snapshot.plan.run_id,
    snapshot.plan.revision,
    expected,
    limits~,
  )
  let ledger = @ledger.Ledger::new(plan, limits~)
  for document in snapshot.documents {
    ledger.ingest(document)
  }
  { ledger, deny_recovered: evidence.deny_recovered, }
}

///|
/// Append original JUnit documents without mutating the archive. The batch is
/// atomic; custom projections, conflicts and unsupported XML are refused.
pub fn Evidence::ingest_reports(
  self : Evidence,
  documents : ArrayView[@ledger.Document],
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> Evidence raise @ledger.InputError {
  limits.validate()
  if documents.length() > limits.max_reports {
    raise bad(
      "ReportLimit", "/documents", "Incoming report count exceeds the budget",
    )
  }
  let snapshot = self.snapshot()
  let ledger = @ledger.Ledger::new(snapshot.plan, limits~)
  for document in snapshot.documents {
    ledger.ingest(document)
  }
  for document in documents {
    let parsed = @junit.parse(
      document.report.source,
      report_id=document.binding.id,
      limits~,
    )
    if parsed != document.report {
      raise bad(
        "ProjectionMismatch",
        "/documents/" + document.binding.id,
        "Incoming observations differ from their original JUnit source",
      )
    }
    ledger.ingest(document)
  }
  { ledger, deny_recovered: self.deny_recovered, }
}