///|
/// A persisted scan state: unique, sorted entries plus the effective scope,
/// completeness and scan issues of the scan that produced them. Snapshots never
/// store file content, timestamps or host-specific absolute paths.
pub struct Snapshot {
  profile : String
  complete : Bool
  scope : Array[String]
  entries : Array[PathEntry]
  scan_issues : Array[ScanIssue]
} derive(Eq, Debug)

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

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

///|
/// Snapshot entries sort by path then kind so the persisted document is stable
/// across scans and executable backends.
fn snapshot_entry_order(a : PathEntry, b : PathEntry) -> Int {
  let order = ordinal_compare(a.path, b.path)
  if order != 0 {
    return order
  }
  ordinal_compare(source_kind_name(a.kind), source_kind_name(b.kind))
}

///|
/// Build a canonical snapshot from scanned entries. Duplicated identical
/// entries collapse, a path with conflicting kinds is rejected, and the scope
/// is validated and normalized. A snapshot with no scan issues is complete.
pub fn build_snapshot(
  entries : Array[PathEntry],
  scan_issues : Array[ScanIssue],
  scope : Array[String],
) -> Result[Snapshot, InputError] {
  let validated = match parse_scope(scope) {
    Err(error) => return Err(error)
    Ok(scope) => scope
  }
  let by_path : Map[String, EntryKind] = Map([])
  for entry in entries {
    match by_path.get(entry.path) {
      Some(existing) =>
        if existing != entry.kind {
          return Err({
            code: "INPUT_SCHEMA",
            message: "Snapshot path has conflicting kinds.",
          })
        }
      None => by_path[entry.path] = entry.kind
    }
  }
  let unique : Array[PathEntry] = []
  for path in sorted_keys(by_path) {
    unique.push({ path, kind: by_path[path], })
  }
  let issues = scan_issues.copy()
  sort_scan_issues(issues)
  Ok({
    profile: "portable-windows-v1",
    complete: issues.is_empty(),
    scope: effective_scope_patterns(validated),
    entries: unique,
    scan_issues: issues,
  })
}

///|
/// Parse a snapshot document. The document must be canonical: entries sorted
/// and unique, scope normalized, scan issues well-formed, and only known
/// fields present. Corrupted or non-canonical input is rejected.
pub fn parse_snapshot(text : String) -> Result[Snapshot, InputError] {
  let value = @json.parse(text) catch {
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "Invalid JSON in the snapshot.",
      })
  }
  guard value is Object(fields) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "The snapshot must be a JSON object.",
    })
  }
  match fields.get("format") {
    Some(String("moonportcheck-snapshot")) => ()
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "Unsupported snapshot format.",
      })
  }
  match fields.get("version") {
    Some(Number(n, ..)) if n == 1.0 => ()
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "Unsupported snapshot version.",
      })
  }
  guard fields.get("profile") is Some(String(profile)) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "The snapshot requires a string profile.",
    })
  }
  let complete = match fields.get("complete") {
    Some(True) => true
    Some(False) => false
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The snapshot requires a boolean complete.",
      })
  }
  let scope = match fields.get("scope") {
    Some(Array(items)) =>
      match collect_snapshot_strings(items, "scope") {
        Err(error) => return Err(error)
        Ok(strings) => strings
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The snapshot requires a scope array.",
      })
  }
  let normalized_scope = match parse_scope(scope) {
    Err(error) => return Err(error)
    Ok(parsed) => effective_scope_patterns(parsed)
  }
  let entries = match fields.get("entries") {
    Some(Array(items)) =>
      match parse_snapshot_entries(items) {
        Err(error) => return Err(error)
        Ok(entries) => entries
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The snapshot requires an entries array.",
      })
  }
  let issues = match fields.get("scan_issues") {
    Some(Array(items)) =>
      match parse_snapshot_issues(items) {
        Err(error) => return Err(error)
        Ok(issues) => issues
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The snapshot requires a scan_issues array.",
      })
  }
  for name in fields.keys() {
    match name {
      "format"
      | "version"
      | "profile"
      | "complete"
      | "scope"
      | "entries"
      | "scan_issues" => ()
      _ =>
        return Err({
          code: "INPUT_SCHEMA",
          message: "Unknown snapshot field: " + name,
        })
    }
  }
  if complete && !issues.is_empty() {
    return Err({
      code: "INPUT_SCHEMA",
      message: "A complete snapshot cannot contain scan issues.",
    })
  }
  sort_scan_issues(issues)
  Ok({
    profile,
    complete,
    scope: normalized_scope,
    entries,
    scan_issues: issues,
  })
}

///|
/// Stable schema 1 snapshot serialization with fixed field order.
pub fn render_snapshot_json(snapshot : Snapshot) -> String {
  let entries = snapshot.entries.copy()
  entries.sort_by(snapshot_entry_order)
  let issues = snapshot.scan_issues.copy()
  sort_scan_issues(issues)
  let scope = snapshot.scope.copy()
  scope.sort_by(ordinal_compare)
  "{\"format\":\"moonportcheck-snapshot\",\"version\":1,\"profile\":" +
  quote(snapshot.profile) +
  ",\"complete\":" +
  snapshot.complete.to_json().stringify() +
  ",\"scope\":" +
  json_strings(scope) +
  ",\"entries\":[" +
  entries
  .map(entry => {
    "{\"path\":" +
    quote(entry.path) +
    ",\"kind\":" +
    quote(source_kind_name(entry.kind)) +
    "}"
  })
  .join(",") +
  "],\"scan_issues\":[" +
  issues
  .map(issue => {
    "{\"code\":" +
    quote(issue.code) +
    ",\"path\":" +
    quote(issue.path) +
    ",\"message\":" +
    quote(issue.message) +
    "}"
  })
  .join(",") +
  "]}"
}

///|
/// Audit a snapshot for the current profile. The snapshot's own effective
/// scope is inherited; any extra exclusions can only shrink the checked set.
/// The snapshot's stored scan issues remain: an incomplete snapshot always
/// stays incomplete. The source is recorded as "snapshot".
pub fn audit_snapshot(
  snapshot : Snapshot,
  extra_scope : Array[String],
) -> Result[DetailedReport, InputError] {
  if snapshot.profile != "portable-windows-v1" {
    return Err({
      code: "INPUT_SCHEMA",
      message: "Snapshot profile does not match the active profile.",
    })
  }
  let combined = snapshot.scope.copy()
  for pattern in extra_scope {
    combined.push(pattern)
  }
  let scope = match parse_scope(combined) {
    Err(error) => return Err(error)
    Ok(scope) => scope
  }
  let base = audit_with_exclusions(
    snapshot.entries,
    default_audit_options(),
    scope,
  )
  let issues = snapshot.scan_issues.copy()
  sort_scan_issues(issues)
  {
    schema_version: base.schema_version,
    profile: base.profile,
    source: "snapshot",
    complete: snapshot.complete && base.complete && issues.is_empty(),
    summary: {
      entries: base.summary.entries,
      files: base.summary.files,
      directories: base.summary.directories,
      diagnostic_groups: base.diagnostics.length(),
      scan_issues: issues.length(),
    },
    diagnostics: base.diagnostics,
    limitations: base.limitations,
    scan_issues: issues,
    scope: base.scope,
    excluded_entries: base.excluded_entries,
    pruned_directories: base.pruned_directories,
  }
  |> Ok
}

///|
fn collect_snapshot_strings(
  items : Array[Json],
  field : String,
) -> Result[Array[String], InputError] {
  let strings : Array[String] = []
  for item in items {
    guard item is String(text) else {
      return Err({
        code: "INPUT_SCHEMA",
        message: "Snapshot " + field + " must contain only strings.",
      })
    }
    strings.push(text)
  }
  Ok(strings)
}

///|
/// Parse snapshot entries enforcing strict ascending order and uniqueness.
fn parse_snapshot_entries(
  items : Array[Json],
) -> Result[Array[PathEntry], InputError] {
  let entries : Array[PathEntry] = []
  for index, item in items {
    guard item is Object(fields) else {
      return Err({
        code: "INPUT_SCHEMA",
        message: "Snapshot entry " + index.to_string() + " must be an object.",
      })
    }
    guard fields.get("path") is Some(String(path)) else {
      return Err({
        code: "INPUT_SCHEMA",
        message: "Snapshot entry " +
        index.to_string() +
        " must have a string path.",
      })
    }
    let kind = match fields.get("kind") {
      Some(String("file")) => File
      Some(String("directory")) => Directory
      _ =>
        return Err({
          code: "INPUT_SCHEMA",
          message: "Snapshot entry " +
          index.to_string() +
          " kind must be file or directory.",
        })
    }
    let entry = { path, kind, }
    if !entries.is_empty() &&
      ordinal_compare(entries[entries.length() - 1].path, entry.path) >= 0 {
      return Err({
        code: "INPUT_SCHEMA",
        message: "Snapshot entries must be sorted and unique.",
      })
    }
    entries.push(entry)
  }
  Ok(entries)
}

///|
fn parse_snapshot_issues(
  items : Array[Json],
) -> Result[Array[ScanIssue], InputError] {
  let issues : Array[ScanIssue] = []
  for index, item in items {
    guard item is Object(fields) else {
      return Err({
        code: "INPUT_SCHEMA",
        message: "Snapshot scan issue " +
        index.to_string() +
        " must be an object.",
      })
    }
    guard fields.get("code") is Some(String(code)) else {
      return Err({
        code: "INPUT_SCHEMA",
        message: "Snapshot scan issue " +
        index.to_string() +
        " requires a code.",
      })
    }
    guard fields.get("path") is Some(String(path)) else {
      return Err({
        code: "INPUT_SCHEMA",
        message: "Snapshot scan issue " +
        index.to_string() +
        " requires a path.",
      })
    }
    guard fields.get("message") is Some(String(message)) else {
      return Err({
        code: "INPUT_SCHEMA",
        message: "Snapshot scan issue " +
        index.to_string() +
        " requires a message.",
      })
    }
    issues.push({ code, path, message, })
  }
  Ok(issues)
}