///|
/// Validated audit settings. Use default_audit_options to create v0.2 settings.
pub struct AuditOptions {
  priv sample_limit : Int
}

///|
pub fn default_audit_options() -> AuditOptions {
  { sample_limit: 5, }
}

///|
pub(all) struct SourceMember {
  path : String
  kind : EntryKind
  count : Int
} derive(Eq, Debug)

///|
pub(all) struct DetailedDiagnostic {
  code : String
  severity : String
  paths : Array[String]
  occurrences : Int
  message : String
  anchor : String
  group_key : String
  members : Array[SourceMember]
  source_examples : Array[SourceMember]
  source_total : Int
  sources_truncated : Bool
} derive(Eq, Debug)

///|
pub(all) struct ScanIssue {
  code : String
  path : String
  message : String
} derive(Eq, Debug)

///|
pub(all) struct DetailedReport {
  schema_version : Int
  profile : String
  source : String
  complete : Bool
  summary : Summary
  diagnostics : Array[DetailedDiagnostic]
  limitations : Array[String]
  scan_issues : Array[ScanIssue]
  scope : Array[String]
  excluded_entries : Int
  pruned_directories : Array[String]
} derive(Eq, Debug)

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

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

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

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

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

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

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

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

///|
priv struct SourceEvidence {
  item : SourceMember
  spelling : String
  file_role : Bool
  directory_role : Bool
}

///|
fn source_kind_name(kind : EntryKind) -> String {
  match kind {
    File => "file"
    Directory => "directory"
  }
}

///|
fn source_member_order(a : SourceMember, b : SourceMember) -> 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))
}

///|
fn detailed_order(a : DetailedDiagnostic, b : DetailedDiagnostic) -> Int {
  let order = ordinal_compare(a.code, b.code)
  if order != 0 {
    return order
  }
  ordinal_compare(a.anchor, b.anchor)
}

///|
fn sort_scan_issues(issues : Array[ScanIssue]) -> Unit {
  issues.sort_by((a, b) => {
    let code = ordinal_compare(a.code, b.code)
    if code != 0 {
      return code
    }
    let path = ordinal_compare(a.path, b.path)
    if path != 0 {
      return path
    }
    ordinal_compare(a.message, b.message)
  })
}

///|
fn source_samples(
  code : String,
  evidence : Array[SourceEvidence],
  limit : Int,
) -> Array[SourceMember] {
  let selected : Array[Int] = []
  if code == "PATH_CASE_COLLISION" && !evidence.is_empty() {
    selected.push(0)
    for i in 1..= limit {
      break
    }
    if !selected.contains(i) {
      selected.push(i)
    }
  }
  // The two mandatory witnesses above fit the private default limit of five.
  let samples = selected.map(i => evidence[i].item)
  samples.sort_by(source_member_order)
  samples
}

///|
/// Preserve v1 diagnostic meaning and add original input evidence only where needed.
pub fn audit_with_options(
  entries : Array[PathEntry],
  options : AuditOptions,
) -> DetailedReport {
  let base = audit(entries)
  let counts : Map[String, PathCounts] = Map([])
  for entry in entries {
    let count = match counts.get(entry.path) {
      Some(count) => count
      None => {
        let count = { files: 0, directories: 0, }
        counts[entry.path] = count
        count
      }
    }
    match entry.kind {
      File => count.files += 1
      Directory => count.directories += 1
    }
  }
  let conflicts : Map[String, Array[SourceEvidence]] = Map([])
  let structurally_invalid : Map[String, Bool] = Map([])
  for diagnostic in base.diagnostics {
    match diagnostic.code {
      "PATH_CASE_COLLISION" | "PATH_KIND_CONFLICT" => {
        let anchor = ascii_lower(diagnostic.paths[0])
        if !conflicts.contains(anchor) {
          conflicts[anchor] = []
        }
      }
      "PATH_EMPTY"
      | "PATH_ABSOLUTE"
      | "PATH_DRIVE"
      | "PATH_EMPTY_COMPONENT"
      | "PATH_DOT_COMPONENT" => structurally_invalid[diagnostic.paths[0]] = true
      _ => ()
    }
  }
  let by_path : Map[String, Array[SourceMember]] = Map([])
  for path in sorted_keys(counts) {
    let count = counts[path]
    let members : Array[SourceMember] = []
    if count.directories > 0 {
      members.push({ path, kind: Directory, count: count.directories, })
    }
    if count.files > 0 {
      members.push({ path, kind: File, count: count.files, })
    }
    by_path[path] = members
    if conflicts.is_empty() || structurally_invalid.contains(path) {
      continue
    }
    let parts = path.split("/").map(p => p.to_owned()).collect()
    let mut prefix = ""
    for i, part in parts {
      prefix = if i == 0 { part } else { prefix + "/" + part }
      guard conflicts.get(ascii_lower(prefix)) is Some(sources) else {
        continue
      }
      for item in members {
        sources.push({
          item,
          spelling: prefix,
          file_role: i == parts.length() - 1 && item.kind == File,
          directory_role: i < parts.length() - 1 || item.kind == Directory,
        })
      }
    }
  }
  let diagnostics = base.diagnostics.map(d => {
    let grouped = d.code == "PATH_CASE_COLLISION" ||
      d.code == "PATH_KIND_CONFLICT"
    let anchor = if grouped { ascii_lower(d.paths[0]) } else { d.paths[0] }
    let evidence = if grouped {
      conflicts[anchor]
    } else {
      by_path[anchor].map(item => {
        item,
        spelling: anchor,
        file_role: item.kind == File,
        directory_role: item.kind == Directory,
      })
    }
    let members = evidence.map(source => source.item)
    let source_examples = source_samples(d.code, evidence, options.sample_limit)
    {
      code: d.code,
      severity: d.severity,
      paths: d.paths,
      occurrences: d.occurrences,
      message: d.message,
      anchor,
      group_key: json_strings([d.code, anchor]),
      members,
      source_examples,
      source_total: members.length(),
      sources_truncated: members.length() > source_examples.length(),
    }
  })
  diagnostics.sort_by(detailed_order)
  {
    schema_version: 2,
    profile: base.profile,
    source: base.source,
    complete: base.complete,
    summary: base.summary,
    diagnostics,
    limitations: base.limitations,
    scan_issues: [],
    scope: [],
    excluded_entries: 0,
    pruned_directories: [],
  }
}

///|
/// Add scan issues without inventing file/directory evidence or losing prior issues.
pub fn with_scan_issues(
  report : DetailedReport,
  issues : Array[ScanIssue],
) -> DetailedReport {
  let combined = report.scan_issues.copy()
  for issue in issues {
    combined.push(issue)
  }
  sort_scan_issues(combined)
  {
    schema_version: report.schema_version,
    profile: report.profile,
    source: "scan",
    complete: report.complete && combined.is_empty(),
    summary: {
      entries: report.summary.entries,
      files: report.summary.files,
      directories: report.summary.directories,
      diagnostic_groups: report.diagnostics.length(),
      scan_issues: combined.length(),
    },
    diagnostics: report.diagnostics,
    limitations: report.limitations,
    scan_issues: combined,
    scope: report.scope,
    excluded_entries: report.excluded_entries,
    pruned_directories: report.pruned_directories,
  }
}

///|
fn render_source_members(members : Array[SourceMember]) -> String {
  let sorted = members.copy()
  sorted.sort_by(source_member_order)
  "[" +
  sorted
  .map(item => {
    "{\"path\":" +
    quote(item.path) +
    ",\"kind\":" +
    quote(source_kind_name(item.kind)) +
    ",\"count\":\{item.count}}"
  })
  .join(",") +
  "]"
}

///|
/// Deterministic schema 2 serialization; v1 render_json remains unchanged.
pub fn render_detailed_json(report : DetailedReport) -> String {
  let diagnostics = report.diagnostics.copy()
  diagnostics.sort_by(detailed_order)
  let rendered = diagnostics.map(d => {
    let paths = d.paths.copy()
    paths.sort_by(ordinal_compare)
    "{\"code\":" +
    quote(d.code) +
    ",\"severity\":" +
    quote(d.severity) +
    ",\"paths\":" +
    json_strings(paths) +
    ",\"occurrences\":\{d.occurrences},\"message\":" +
    quote(d.message) +
    ",\"anchor\":" +
    quote(d.anchor) +
    ",\"group_key\":" +
    quote(d.group_key) +
    ",\"members\":" +
    render_source_members(d.members) +
    ",\"source_examples\":" +
    render_source_members(d.source_examples) +
    ",\"source_total\":\{d.source_total},\"sources_truncated\":\{d.sources_truncated}}"
  })
  let issues = report.scan_issues.copy()
  sort_scan_issues(issues)
  let rendered_issues = issues.map(issue => {
    "{\"code\":" +
    quote(issue.code) +
    ",\"path\":" +
    quote(issue.path) +
    ",\"message\":" +
    quote(issue.message) +
    "}"
  })
  let s = report.summary
  "{\"schema_version\":\{report.schema_version},\"profile\":" +
  quote(report.profile) +
  ",\"source\":" +
  quote(report.source) +
  ",\"complete\":\{report.complete}," +
  "\"summary\":{\"entries\":\{s.entries},\"files\":\{s.files},\"directories\":\{s.directories}," +
  "\"diagnostic_groups\":\{s.diagnostic_groups},\"scan_issues\":\{s.scan_issues}}," +
  "\"diagnostics\":[" +
  rendered.join(",") +
  "],\"limitations\":" +
  json_strings(report.limitations) +
  ",\"scan_issues\":[" +
  rendered_issues.join(",") +
  "],\"scope\":" +
  json_strings(report.scope) +
  ",\"excluded_entries\":\{report.excluded_entries},\"pruned_directories\":" +
  json_strings(report.pruned_directories) +
  "}"
}

///|
/// Human-readable schema 2 findings with bounded original-entry witnesses.
pub fn render_detailed_text(report : DetailedReport) -> String {
  let status = if !report.complete {
    "INCOMPLETE"
  } else if report.diagnostics.is_empty() {
    "PASS (covered rules only)"
  } else {
    "FINDINGS"
  }
  let lines = [
    "MoonPortCheck — \{report.profile}; schema=\{report.schema_version}",
    "\{status}; source=\{report.source}; entries=\{report.summary.entries}; files=\{report.summary.files}; directories=\{report.summary.directories}",
    "Scope exclusions: " +
    json_strings(report.scope) +
    "; excluded_entries=\{report.excluded_entries}; pruned_directories=" +
    json_strings(report.pruned_directories),
  ]
  let diagnostics = report.diagnostics.copy()
  diagnostics.sort_by(detailed_order)
  for d in diagnostics {
    let paths = d.paths.copy()
    paths.sort_by(ordinal_compare)
    lines.push(
      "[\{d.code}] " +
      paths.map(quote).join(", ") +
      " (occurrences=\{d.occurrences}): " +
      d.message,
    )
    lines.push("  Group: " + d.group_key)
    lines.push(
      "  Source samples: \{d.source_examples.length()}/\{d.source_total}; truncated=\{d.sources_truncated}",
    )
    let samples = d.source_examples.copy()
    samples.sort_by(source_member_order)
    for item in samples {
      lines.push(
        "  - " +
        quote(item.path) +
        " (kind=" +
        source_kind_name(item.kind) +
        "; count=\{item.count})",
      )
    }
  }
  let issues = report.scan_issues.copy()
  sort_scan_issues(issues)
  for issue in issues {
    lines.push(
      "[\{issue.code}] " + quote(issue.path) + ": " + quote(issue.message),
    )
  }
  lines.push("Coverage limits:")
  for limitation in report.limitations {
    lines.push("- " + limitation)
  }
  lines.join("\n")
}

///|
/// Markdown rendering of a schema 2 report. The status, scopes, diagnostics
/// and scan issues mirror `render_detailed_text` so every format reaches the
/// same conclusion; paths are escaped so a hostile name cannot break a table.
pub fn render_detailed_markdown(report : DetailedReport) -> String {
  let status = if !report.complete {
    "INCOMPLETE"
  } else if report.diagnostics.is_empty() {
    "PASS (covered rules only)"
  } else {
    "FINDINGS"
  }
  let lines = [
    "# MoonPortCheck report",
    "",
    "**Status:** `" +
    status +
    "` · **Profile:** `" +
    report.profile +
    "` · **Source:** `" +
    report.source +
    "` · **Entries:** " +
    report.summary.entries.to_string() +
    " (files " +
    report.summary.files.to_string() +
    ", directories " +
    report.summary.directories.to_string() +
    ")",
    "",
    "**Scope exclusions:** `" +
    json_strings(report.scope) +
    "`; excluded_entries=" +
    report.excluded_entries.to_string() +
    "; pruned_directories=`" +
    json_strings(report.pruned_directories) +
    "`",
    "",
  ]
  lines.push("## Findings")
  lines.push("")
  let diagnostics = report.diagnostics.copy()
  diagnostics.sort_by(detailed_order)
  if diagnostics.is_empty() {
    lines.push("No findings.")
  } else {
    lines.push("| Code | Paths | Occurrences | Message |")
    lines.push("| --- | --- | --- | --- |")
    for d in diagnostics {
      let paths = d.paths.copy()
      paths.sort_by(ordinal_compare)
      lines.push(
        "| `" +
        md_cell(d.code) +
        "` | " +
        paths.map(path => "`" + md_cell(path) + "`").join(", ") +
        " | " +
        d.occurrences.to_string() +
        " | " +
        md_cell(d.message) +
        " |",
      )
    }
  }
  lines.push("")
  lines.push("## Scan issues")
  lines.push("")
  let issues = report.scan_issues.copy()
  sort_scan_issues(issues)
  if issues.is_empty() {
    lines.push("No scan issues.")
  } else {
    lines.push("| Code | Path | Message |")
    lines.push("| --- | --- | --- |")
    for issue in issues {
      lines.push(
        "| `" +
        md_cell(issue.code) +
        "` | `" +
        md_cell(issue.path) +
        "` | " +
        md_cell(issue.message) +
        " |",
      )
    }
  }
  lines.push("")
  lines.push("## Coverage limits")
  if report.limitations.is_empty() {
    lines.push("None.")
  } else {
    for limitation in report.limitations {
      lines.push("- " + md_cell(limitation))
    }
  }
  lines.join("\n")
}

///|
fn parse_int_field(
  fields : Map[String, Json],
  name : String,
) -> Result[Int, InputError] {
  match fields.get(name) {
    Some(Number(n, ..)) => {
      let int = n.to_int()
      if int.to_double() == n {
        Ok(int)
      } else {
        Err({
          code: "INPUT_SCHEMA",
          message: "The " + name + " field must be an integer.",
        })
      }
    }
    _ =>
      Err({
        code: "INPUT_SCHEMA",
        message: "The " + name + " field requires an integer.",
      })
  }
}

///|
fn reject_unknown_fields(
  fields : Map[String, Json],
  allowed : Array[String],
) -> Result[Unit, InputError] {
  for name in fields.keys() {
    if !allowed.contains(name) {
      return Err({ code: "INPUT_SCHEMA", message: "Unknown field: " + name, })
    }
  }
  Ok(())
}

///|
fn parse_diagnostic_strings(
  items : Array[Json],
  label : String,
) -> Result[Array[String], InputError] {
  let strings : Array[String] = []
  for index in 0.. Result[Array[SourceMember], InputError] {
  let members : Array[SourceMember] = []
  for index in 0.. return Err(error)
      Ok(_) => ()
    }
    guard fields.get("path") is Some(String(path)) else {
      return Err({
        code: "INPUT_SCHEMA",
        message: "A source member requires a path.",
      })
    }
    let kind = match fields.get("kind") {
      Some(String("file")) => File
      Some(String("directory")) => Directory
      _ =>
        return Err({
          code: "INPUT_SCHEMA",
          message: "A source member kind must be file or directory.",
        })
    }
    let count = match parse_int_field(fields, "count") {
      Err(error) => return Err(error)
      Ok(count) => count
    }
    members.push({ path, kind, count, })
  }
  Ok(members)
}

///|
fn parse_detailed_diagnostic(
  fields : Map[String, Json],
) -> Result[DetailedDiagnostic, InputError] {
  match
    reject_unknown_fields(fields, [
      "code", "severity", "paths", "occurrences", "message", "anchor", "group_key",
      "members", "source_examples", "source_total", "sources_truncated",
    ]) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  guard fields.get("code") is Some(String(code)) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "A diagnostic requires a code.",
    })
  }
  guard fields.get("severity") is Some(String(severity)) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "A diagnostic requires a severity.",
    })
  }
  let paths = match fields.get("paths") {
    Some(Array(items)) =>
      match parse_diagnostic_strings(items, "paths") {
        Err(error) => return Err(error)
        Ok(values) => values
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "A diagnostic requires a paths array.",
      })
  }
  let occurrences = match parse_int_field(fields, "occurrences") {
    Err(error) => return Err(error)
    Ok(value) => value
  }
  guard fields.get("message") is Some(String(message)) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "A diagnostic requires a message.",
    })
  }
  guard fields.get("anchor") is Some(String(anchor)) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "A diagnostic requires an anchor.",
    })
  }
  guard fields.get("group_key") is Some(String(group_key)) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "A diagnostic requires a group_key.",
    })
  }
  let members = match fields.get("members") {
    Some(Array(items)) =>
      match parse_source_members(items) {
        Err(error) => return Err(error)
        Ok(values) => values
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "A diagnostic requires a members array.",
      })
  }
  let source_examples = match fields.get("source_examples") {
    Some(Array(items)) =>
      match parse_source_members(items) {
        Err(error) => return Err(error)
        Ok(values) => values
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "A diagnostic requires a source_examples array.",
      })
  }
  let source_total = match parse_int_field(fields, "source_total") {
    Err(error) => return Err(error)
    Ok(value) => value
  }
  let sources_truncated = match fields.get("sources_truncated") {
    Some(True) => true
    Some(False) => false
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "A diagnostic requires a boolean sources_truncated.",
      })
  }
  Ok({
    code,
    severity,
    paths,
    occurrences,
    message,
    anchor,
    group_key,
    members,
    source_examples,
    source_total,
    sources_truncated,
  })
}

///|
/// Parse a schema 2 report document produced by render_detailed_json. The
/// parser is strict about field names and rejects unknown members so a
/// baseline cannot silently drop a newly added report field.
pub fn parse_detailed_report(
  text : String,
) -> Result[DetailedReport, InputError] {
  let value = @json.parse(text) catch {
    _ =>
      return Err({ code: "INPUT_JSON", message: "Invalid JSON in the report.", })
  }
  guard value is Object(fields) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "The report must be a JSON object.",
    })
  }
  match
    reject_unknown_fields(fields, [
      "schema_version", "profile", "source", "complete", "summary", "diagnostics",
      "limitations", "scan_issues", "scope", "excluded_entries", "pruned_directories",
      "baseline",
    ]) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  match fields.get("schema_version") {
    Some(Number(n, ..)) if n == 2.0 => ()
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "Unsupported report schema version.",
      })
  }
  guard fields.get("profile") is Some(String(profile)) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "The report requires a string profile.",
    })
  }
  guard fields.get("source") is Some(String(source)) else {
    return Err({
      code: "INPUT_SCHEMA",
      message: "The report requires a source.",
    })
  }
  let complete = match fields.get("complete") {
    Some(True) => true
    Some(False) => false
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The report requires a boolean complete.",
      })
  }
  let summary = match fields.get("summary") {
    Some(Object(summary_fields)) => {
      match
        reject_unknown_fields(summary_fields, [
          "entries", "files", "directories", "diagnostic_groups", "scan_issues",
        ]) {
        Err(error) => return Err(error)
        Ok(_) => ()
      }
      let entries = match parse_int_field(summary_fields, "entries") {
        Err(error) => return Err(error)
        Ok(value) => value
      }
      let files = match parse_int_field(summary_fields, "files") {
        Err(error) => return Err(error)
        Ok(value) => value
      }
      let directories = match parse_int_field(summary_fields, "directories") {
        Err(error) => return Err(error)
        Ok(value) => value
      }
      let diagnostic_groups = match
        parse_int_field(summary_fields, "diagnostic_groups") {
        Err(error) => return Err(error)
        Ok(value) => value
      }
      let scan_issues_summary = match
        parse_int_field(summary_fields, "scan_issues") {
        Err(error) => return Err(error)
        Ok(value) => value
      }
      {
        entries,
        files,
        directories,
        diagnostic_groups,
        scan_issues: scan_issues_summary,
      }
    }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The report requires a summary object.",
      })
  }
  let diagnostics = match fields.get("diagnostics") {
    Some(Array(items)) => {
      let list : Array[DetailedDiagnostic] = []
      for index in 0.. return Err(error)
          Ok(diagnostic) => list.push(diagnostic)
        }
      }
      list
    }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The report requires a diagnostics array.",
      })
  }
  let limitations = match fields.get("limitations") {
    Some(Array(items)) =>
      match parse_diagnostic_strings(items, "limitations") {
        Err(error) => return Err(error)
        Ok(values) => values
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The report requires a limitations array.",
      })
  }
  let scan_issues = match fields.get("scan_issues") {
    Some(Array(items)) => {
      for item in items {
        if item is Object(issue_fields) {
          match
            reject_unknown_fields(issue_fields, ["code", "path", "message"]) {
            Err(error) => return Err(error)
            Ok(_) => ()
          }
        }
      }
      match parse_snapshot_issues(items) {
        Err(error) => return Err(error)
        Ok(values) => values
      }
    }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The report requires a scan_issues array.",
      })
  }
  let scope = match fields.get("scope") {
    Some(Array(items)) =>
      match collect_snapshot_strings(items, "scope") {
        Err(error) => return Err(error)
        Ok(values) => values
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The report requires a scope array.",
      })
  }
  let excluded_entries = match parse_int_field(fields, "excluded_entries") {
    Err(error) => return Err(error)
    Ok(value) => value
  }
  let pruned_directories = match fields.get("pruned_directories") {
    Some(Array(items)) =>
      match parse_diagnostic_strings(items, "pruned_directories") {
        Err(error) => return Err(error)
        Ok(values) => values
      }
    _ =>
      return Err({
        code: "INPUT_SCHEMA",
        message: "The report requires a pruned_directories array.",
      })
  }
  Ok({
    schema_version: 2,
    profile,
    source,
    complete,
    summary,
    diagnostics,
    limitations,
    scan_issues,
    scope,
    excluded_entries,
    pruned_directories,
  })
}