///|
/// Exact decoded UTF-8 XML and a content-addressed flat filename. No arbitrary
/// paths or binary members are supported by the evidence file-pack profile.
pub(all) struct PackFile {
  name : String
  xml : String
} derive(Debug, Eq)

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

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

///|
/// Full expected inventory plus an integrity manifest; accepted XML is stored
/// separately. A missing expected report remains missing, not a missing member.
struct PackIndex {
  plan : @ledger.RunPlan
  manifest : IntegrityManifest
}

///|
fn pack_name(hash : String) -> String {
  "sha256-" + hash + ".xml"
}

///|
/// Independent sorted list of required members, deduplicated by exact digest.
pub fn PackIndex::filenames(self : PackIndex) -> Array[String] {
  let names : Set[String] = Set([])
  for document in self.manifest.documents {
    names.add(pack_name(document.sha256))
  }
  let result = names.to_array()
  result.sort()
  result
}

///|
/// Detach exact original XML without losing the full plan, policy or bindings.
/// Both returned values are independent of the input. Index and members can
/// be passed to any storage adapter; restore checks all hashes before replay.
pub fn Evidence::detach(
  self : Evidence,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> (PackIndex, Array[PackFile]) raise @ledger.InputError {
  let manifest = self.integrity(limits~)
  let snapshot = self.snapshot()
  let files : Map[String, String] = Map([])
  for document in snapshot.documents {
    let name = pack_name(sha256_text(document.report.source))
    match files.get(name) {
      Some(previous) if previous != document.report.source =>
        raise bad(
          "PackCollision",
          "/files/" + name,
          "Different XML sources share a digest; refusing ambiguous storage",
        )
      _ => files[name] = document.report.source
    }
  }
  let index : PackIndex = { plan: snapshot.plan, manifest, }
  ignore(index.encode(limits~))
  let members = files.to_array().map(pair => { name: pair.0, xml: pair.1, })
  members.sort_by((a, b) => a.name.compare(b.name))
  (index, members)
}

///|
pub fn PackIndex::encode(
  self : PackIndex,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> String raise @ledger.InputError {
  let plan = @ledger.RunPlan::new(
    self.plan.run_id,
    self.plan.revision,
    self.plan.reports,
    limits~,
  )
  let integrity = strict_json(self.manifest.encode(limits~), limits)
  let result = Json::object({
    "schema": Json::number(1),
    "kind": Json::string("moontestledger/file-pack"),
    "plan": plan_json(plan),
    "integrity": integrity,
  }).stringify(indent=2)
  if result.length() > limits.max_source_units {
    raise bad(
      "SourceLimit", "$", "File-pack index exceeds the configured JSON budget",
    )
  }
  result
}

///|
/// Require exactly the declared members, verify original UTF-8 bytes, then
/// replay all XML against the full plan and verify the complete archive digest.
/// Missing stored members and undeclared extras are invalid, never truncated.
pub fn PackIndex::restore(
  self : PackIndex,
  files : ArrayView[PackFile],
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> Evidence raise @ledger.InputError {
  ignore(self.encode(limits~))
  if files.length() > limits.max_reports {
    raise bad("ReportLimit", "/files", "Too many file-pack members")
  }
  let names = self.filenames()
  let required_names = Set::from_array(names)
  let sources : Map[String, String] = Map([])
  let mut total_units = 0L
  for file in files {
    if !required_names.contains(file.name) {
      raise bad(
        "UnexpectedPackFile",
        "/files/" + file.name,
        "Member is not declared by the pack index",
      )
    }
    if sources.contains(file.name) {
      raise bad(
        "DuplicatePackFile",
        "/files/" + file.name,
        "Member name appears more than once",
      )
    }
    if file.xml.length() > limits.max_source_units {
      raise bad(
        "SourceLimit",
        "/files/" + file.name,
        "XML member exceeds the source budget",
      )
    }
    total_units += file.xml.length().to_int64()
    if total_units > limits.max_total_source_units.to_int64() {
      raise bad(
        "SourceLimit", "/files", "Combined unique member source budget exceeded",
      )
    }
    if pack_name(sha256_text(file.xml)) != file.name {
      raise mismatch(
        "/files/" + file.name,
        "Original XML digest differs from its content-addressed filename",
      )
    }
    sources[file.name] = file.xml
  }
  for name in names {
    if !sources.contains(name) {
      raise bad(
        "MissingPackFile",
        "/files/" + name,
        "A declared accepted XML member is missing",
      )
    }
  }
  let ledger = @ledger.Ledger::new(self.plan, limits~)
  for receipt in self.manifest.documents {
    let name = pack_name(receipt.sha256)
    let source = sources[name]
    if @utf8.encode(source).length() != receipt.utf8_bytes {
      raise mismatch(
        "/files/" + name,
        "XML byte length differs from the receipt",
      )
    }
    let report = @junit.parse(source, report_id=receipt.binding.id, limits~)
    ledger.ingest({ binding: receipt.binding, report, })
  }
  let evidence : Evidence = {
    ledger,
    deny_recovered: self.manifest.deny_recovered,
  }
  evidence.check_integrity(self.manifest, limits~)
  evidence
}

///|
/// Strict index validation before any member access. Every planned report
/// must have exactly one matching receipt or an explicit missing-report ID.
pub fn decode_pack_index(
  source : String,
  limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> PackIndex raise @ledger.InputError {
  let fields = object(strict_json(source, limits), "$")
  keys(fields, ["schema", "kind", "plan", "integrity"], "$")
  schema(fields)
  if string_field(fields, "kind", "$") != "moontestledger/file-pack" {
    raise bad("PackKind", "$/kind", "Expected a MoonTestLedger file-pack index")
  }
  let plan = decode_plan(required(fields, "plan", "$").stringify(), limits~)
  let manifest = decode_integrity(
    required(fields, "integrity", "$").stringify(),
    limits~,
  )
  if manifest.run_id != plan.run_id ||
    manifest.revision != plan.revision ||
    manifest.plan_sha256 != sha256_text(encode_plan(plan)) {
    raise mismatch("$/plan", "Full plan differs from the integrity manifest")
  }
  let reports : Map[String, @ledger.ExpectedReport] = Map([])
  for report in plan.reports {
    reports[report.id] = report
  }
  let accounted : Set[String] = Set([])
  for receipt in manifest.documents {
    guard reports.get(receipt.binding.id) is Some(report) else {
      raise bad(
        "UnexpectedReport", "$/integrity/documents", "Receipt identity is not in the full plan",
      )
    }
    if receipt.binding.environment != report.environment ||
      receipt.binding.shard != report.shard ||
      receipt.binding.attempt != report.attempt {
      raise bad(
        "BindingMismatch",
        "$/integrity/documents/" + report.id,
        "Receipt must exactly match its planned environment, shard and attempt",
      )
    }
    accounted.add(report.id)
  }
  for id in manifest.missing_reports {
    if !reports.contains(id) {
      raise bad(
        "UnexpectedReport", "$/integrity/missing_reports", "Missing identity is not in the full plan",
      )
    }
    accounted.add(id)
  }
  if accounted.length() != reports.length() {
    raise bad(
      "PackInventory", "$/integrity", "Every planned report needs a receipt or an explicit missing identity",
    )
  }
  { plan, manifest, }
}