///|
/// Offline dependency vulnerability matching for MoonBit projects.
pub struct Dependency {
  name : String
  version : String
  source : String
  aliases : Array[String]
  scopes : Array[DependencyScope]
} derive(Debug, Eq, ToJson)

///|
pub fn Dependency::Dependency(
  name~ : String,
  version~ : String,
  source~ : String,
  aliases~ : Array[String] = [],
  scopes~ : Array[DependencyScope] = [],
) -> Dependency {
  { name, version, source, aliases, scopes, }
}

///|
/// A declared module with multiple versions in the scanned project tree.
pub struct DependencyConflict {
  name : String
  versions : Array[String]
  sources : Array[String]
} derive(Debug, Eq, ToJson)

///|
/// A deterministic, de-duplicated dependency inventory.
pub struct Inventory {
  dependencies : Array[Dependency]
  conflicts : Array[DependencyConflict]
} derive(Debug, Eq, ToJson)

///|
/// Normalise declarations into a deterministic inventory.
///
/// Declarations with the same package name and version are represented once.
/// A canonical manifest path becomes the evidence location.
pub fn inventory_from_dependencies(
  dependencies : Array[Dependency],
) -> Inventory {
  let normalized : Array[Dependency] = []
  for dependency in dependencies {
    let mut duplicate : Int32? = None
    for index in 0.. {
        let existing = normalized[index]
        let aliases = merge_strings(existing.aliases, dependency.aliases)
        let scopes = merge_scopes(existing.scopes, dependency.scopes)
        let source = if lexicographic_compare(
            dependency.source,
            existing.source,
          ) <
          0 {
          dependency.source
        } else {
          existing.source
        }
        normalized[index] = Dependency(
          name=existing.name,
          version=existing.version,
          source~,
          aliases~,
          scopes~,
        )
      }
      None =>
        normalized.push(
          Dependency(
            name=dependency.name,
            version=dependency.version,
            source=dependency.source,
            aliases=merge_strings([], dependency.aliases),
            scopes=merge_scopes([], dependency.scopes),
          ),
        )
    }
  }
  normalized.sort_by(dependency_compare)
  { dependencies: normalized, conflicts: dependency_conflicts(normalized), }
}

///|
/// Find modules declared at more than one version and sort their evidence.
pub fn dependency_conflicts(
  dependencies : Array[Dependency],
) -> Array[DependencyConflict] {
  let conflicts : Array[DependencyConflict] = []
  let seen : Map[String, Bool] = Map([])
  for dependency in dependencies {
    if !seen.contains(dependency.name) {
      seen[dependency.name] = true
      let versions : Array[String] = []
      let sources : Array[String] = []
      for candidate in dependencies {
        if candidate.name == dependency.name {
          if !versions.contains(candidate.version) {
            versions.push(candidate.version)
          }
          if !sources.contains(candidate.source) {
            sources.push(candidate.source)
          }
        }
      }
      if versions.length() > 1 {
        versions.sort_by(lexicographic_compare)
        sources.sort_by(lexicographic_compare)
        conflicts.push({ name: dependency.name, versions, sources, })
      }
    }
  }
  conflicts
}

///|
fn merge_scopes(
  left : Array[DependencyScope],
  right : Array[DependencyScope],
) -> Array[DependencyScope] {
  let result : Array[DependencyScope] = []
  for scope in [Runtime, Test, WhiteboxTest, Other] {
    if left.contains(scope) || right.contains(scope) {
      result.push(scope)
    }
  }
  result
}

///|
fn merge_strings(left : Array[String], right : Array[String]) -> Array[String] {
  let result : Array[String] = []
  for value in left {
    if value != "" && !result.contains(value) {
      result.push(value)
    }
  }
  for value in right {
    if value != "" && !result.contains(value) {
      result.push(value)
    }
  }
  result.sort_by(lexicographic_compare)
  result
}

///|
fn dependency_compare(left : Dependency, right : Dependency) -> Int32 {
  let by_name = lexicographic_compare(left.name, right.name)
  if by_name != 0 {
    by_name
  } else {
    let by_version = lexicographic_compare(left.version, right.version)
    if by_version != 0 {
      by_version
    } else {
      lexicographic_compare(left.source, right.source)
    }
  }
}

///|
fn lexicographic_compare(left : String, right : String) -> Int32 {
  let left_chars = left.to_array()
  let right_chars = right.to_array()
  let common_length = if left_chars.length() < right_chars.length() {
    left_chars.length()
  } else {
    right_chars.length()
  }
  for index in 0.. right_chars.length() {
    1
  } else {
    0
  }
}

///|
pub struct Advisory {
  id : String
  package_name : String
  affected : String
  severity : String
  summary : String
  fixed_version : String
  references : Array[String]
} derive(Debug, Eq, ToJson, FromJson)

///|
pub struct AdvisoryDatabase {
  schema_version : String
  advisories : Array[Advisory]
} derive(Debug, Eq, ToJson, FromJson)

///|
pub enum FindingStatus {
  Affected
  NotAffected
  Uncomparable
} derive(Debug, Eq, ToJson)

///|
pub struct Finding {
  dependency : Dependency
  advisory : Advisory
  status : FindingStatus
  suppression : PolicySuppression?
  baseline_status : BaselineFindingStatus?
} derive(Debug, Eq, ToJson)

///|
pub struct ScanReport {
  schema_version : String
  dependencies : Array[Dependency]
  findings : Array[Finding]
  uncomparable : Array[Finding]
  conflicts : Array[DependencyConflict]
  policy : PolicySummary?
  baseline : BaselineSummary?
} derive(Debug, Eq, ToJson)

///|
pub fn parse_database(text : String) -> AdvisoryDatabase raise {
  let json = @json.parse(text)
  validate_database_shape(json)
  let database : AdvisoryDatabase = @json.from_json(json)
  validate_database(database)
  database
}

///|
pub fn parse_manifest(
  text : String,
  source~ : String,
) -> Array[Dependency] raise {
  let object : Map[String, Json] = @json.from_json(@json.parse(text))
  let result : Array[Dependency] = []
  match object.get("import") {
    Some(Object(imports)) =>
      for name, value in imports {
        match value {
          String(version) =>
            result.push({ name, version, source, aliases: [], scopes: [], })
          _ =>
            raise ManifestParseError::InvalidManifest(
              source~,
              line=1,
              reason="dependency \{name} must declare a string version",
            )
        }
      }
    _ => ()
  }
  result
}

///|
pub fn scan(
  dependencies : Array[Dependency],
  database : AdvisoryDatabase,
) -> ScanReport raise {
  validate_database(database)
  let findings : Array[Finding] = []
  let uncomparable : Array[Finding] = []
  for dependency in dependencies {
    for advisory in database.advisories {
      if dependency.name == advisory.package_name {
        let status = match_version(dependency.version, advisory.affected)
        let finding = {
          dependency,
          advisory,
          status,
          suppression: None,
          baseline_status: None,
        }
        match status {
          Affected => findings.push(finding)
          Uncomparable => uncomparable.push(finding)
          NotAffected => ()
        }
      }
    }
  }
  {
    schema_version: "6",
    dependencies,
    findings,
    uncomparable,
    conflicts: dependency_conflicts(dependencies),
    policy: None,
    baseline: None,
  }
}

///|
pub fn report_json(report : ScanReport) -> String {
  @json.to_json(report).stringify(indent=2)
}

///|
pub fn report_terminal(report : ScanReport) -> String {
  let lines = [
    "MoonVigil offline scan",
    "Report schema: v\{report.schema_version}",
    "Dependencies: \{report.dependencies.length()}",
    "Affected: \{report.findings.length()}",
    "Uncomparable: \{report.uncomparable.length()}",
    "Conflicting modules: \{report.conflicts.length()}",
  ]
  for conflict in report.conflicts {
    let versions = conflict.versions.join(", ")
    let sources = conflict.sources.join(", ")
    lines.push(
      "dependency conflict: \{conflict.name} versions \{versions} (sources: \{sources})",
    )
  }
  match report.policy {
    Some(policy) => {
      lines.push(
        "Policy: fail on \{policy.fail_on}; scopes \{policy.fail_on_scopes.join(",")}; \{policy.blocking_findings} blocking, \{policy.scope_excluded_findings} out of scope, \{policy.suppressed_findings} suppressed",
      )
      for suppression in policy.unmatched_suppressions {
        lines.push(
          "Warning: suppression did not match \{suppression.advisory_id} \{suppression.package_name}@\{suppression.version} (expires \{suppression.expires_on})",
        )
      }
    }
    None => ()
  }
  match report.baseline {
    Some(baseline) => {
      lines.push(
        "Baseline: \{baseline.new_findings} new, \{baseline.existing_findings} existing, \{baseline.updated_findings} updated, \{baseline.no_longer_detected.length()} no longer detected",
      )
      if baseline.legacy_snapshot {
        lines.push(
          "Warning: schema-v1 baseline has no advisory risk evidence; risk changes cannot be detected for its entries.",
        )
      }
      for entry in baseline.no_longer_detected {
        lines.push(
          "no longer detected: \{entry.advisory_id} \{entry.package_name}@\{entry.version}",
        )
      }
    }
    None => ()
  }
  for finding in report.findings {
    lines.push(terminal_finding(finding, "affected"))
  }
  for finding in report.uncomparable {
    lines.push(terminal_finding(finding, "uncomparable"))
  }
  lines.join("\n")
}

///|
pub fn report_sarif(report : ScanReport) -> String {
  let results : Array[Json] = []
  let rules : Array[Json] = []
  let seen_rules : Map[String, Bool] = Map([])
  for finding in report.findings {
    results.push(sarif_result(finding))
    add_sarif_rule(rules, seen_rules, finding.advisory)
  }
  for finding in report.uncomparable {
    results.push(sarif_result(finding))
    add_sarif_rule(rules, seen_rules, finding.advisory)
  }
  let run_properties : Map[String, Json] = {
    "dependencyConflicts": Json::array(
      report.conflicts.map(dependency_conflict_json),
    ),
  }
  match report.policy {
    Some(policy) => run_properties["policy"] = policy_summary_json(policy)
    None => ()
  }
  match report.baseline {
    Some(baseline) =>
      run_properties["moonvigilBaseline"] = baseline_summary_json(baseline)
    None => ()
  }
  Json::object({
    "version": Json::string("2.1.0"),
    "$schema": Json::string("https://json.schemastore.org/sarif-2.1.0.json"),
    "runs": Json::array([
      Json::object({
        "tool": Json::object({
          "driver": Json::object({
            "name": Json::string("MoonVigil"),
            "rules": Json::array(rules),
          }),
        }),
        "results": Json::array(results),
        "properties": Json::object(run_properties),
      }),
    ]),
  }).stringify(indent=2)
}

///|
fn dependency_conflict_json(conflict : DependencyConflict) -> Json {
  Json::object({
    "package": Json::string(conflict.name),
    "versions": Json::array(conflict.versions.map(Json::string)),
    "sources": Json::array(conflict.sources.map(Json::string)),
  })
}

///|
fn policy_summary_json(policy : PolicySummary) -> Json {
  Json::object({
    "failOn": Json::string(policy.fail_on),
    "failOnScopes": Json::array(policy.fail_on_scopes.map(Json::string)),
    "blockingFindings": Json::number(policy.blocking_findings.to_double()),
    "scopeExcludedFindings": Json::number(
      policy.scope_excluded_findings.to_double(),
    ),
    "suppressedFindings": Json::number(policy.suppressed_findings.to_double()),
    "unmatchedSuppressions": Json::array(
      policy.unmatched_suppressions.map(@json.to_json),
    ),
  })
}

///|
fn baseline_summary_json(baseline : BaselineSummary) -> Json {
  Json::object({
    "snapshotEntries": Json::number(baseline.snapshot_entries.to_double()),
    "newFindings": Json::number(baseline.new_findings.to_double()),
    "existingFindings": Json::number(baseline.existing_findings.to_double()),
    "updatedFindings": Json::number(baseline.updated_findings.to_double()),
    "legacySnapshot": Json::boolean(baseline.legacy_snapshot),
    "noLongerDetected": Json::array(
      baseline.no_longer_detected.map(@json.to_json),
    ),
  })
}

///|
fn terminal_finding(finding : Finding, status : String) -> String {
  let display_status = match (finding.suppression, finding.baseline_status) {
    (Some(_), Some(New)) => "suppressed, new"
    (Some(_), Some(Existing)) => "suppressed, existing"
    (Some(_), Some(Updated)) => "suppressed, updated"
    (Some(_), None) => "suppressed"
    (None, Some(New)) => "new"
    (None, Some(Existing)) => "existing"
    (None, Some(Updated)) => "updated"
    (None, None) => status
  }
  let fixed_version = if finding.advisory.fixed_version == "" {
    "not available"
  } else {
    finding.advisory.fixed_version
  }
  let references = if finding.advisory.references.length() == 0 {
    "none"
  } else {
    finding.advisory.references.join(", ")
  }
  (
    $|[\{display_status}] [\{finding.advisory.severity}] \{finding.advisory.id}
    $|  package: \{finding.dependency.name}
    $|  installed: \{finding.dependency.version}
    $|  affected: \{finding.advisory.affected}
    $|  fixed: \{fixed_version}
    $|  source: \{finding.dependency.source}
    $|  aliases: \{finding.dependency.aliases.join(", ")}
    $|  scopes: \{dependency_scopes_text(finding.dependency.scopes)}
    $|  summary: \{finding.advisory.summary}
    $|  references: \{references}
    $|\{suppression_text(finding.suppression)}
  )
}

///|
fn suppression_text(suppression : PolicySuppression?) -> String {
  match suppression {
    Some(value) =>
      "  suppression: \{value.reason} (expires \{value.expires_on})"
    None => ""
  }
}

///|
fn dependency_scopes_text(scopes : Array[DependencyScope]) -> String {
  if scopes.length() == 0 {
    "unclassified"
  } else {
    scopes.map(dependency_scope_name).join(", ")
  }
}

///|
fn sarif_result(finding : Finding) -> Json {
  let level = match finding.status {
    Affected => sarif_level(finding.advisory.severity)
    Uncomparable => "note"
    NotAffected => "none"
  }
  let message = match finding.status {
    Affected => finding.advisory.summary
    Uncomparable =>
      "MoonVigil could not compare the installed version against this advisory range."
    NotAffected => finding.advisory.summary
  }
  let properties : Map[String, Json] = {
    "package": Json::string(finding.dependency.name),
    "installedVersion": Json::string(finding.dependency.version),
    "affectedRange": Json::string(finding.advisory.affected),
    "fixedVersion": Json::string(finding.advisory.fixed_version),
    "aliases": Json::array(finding.dependency.aliases.map(Json::string)),
    "scopes": Json::array(
      finding.dependency.scopes.map(fn(scope) {
        Json::string(dependency_scope_name(scope))
      }),
    ),
    "references": Json::array(finding.advisory.references.map(Json::string)),
    "matchStatus": Json::string(match_status_name(finding.status)),
  }
  let result : Map[String, Json] = {
    "ruleId": Json::string(finding.advisory.id),
    "level": Json::string(level),
    "message": Json::object({ "text": Json::string(message) }),
    "locations": Json::array([
      Json::object({
        "physicalLocation": Json::object({
          "artifactLocation": Json::object({
            "uri": Json::string(sarif_artifact_uri(finding.dependency.source)),
          }),
        }),
      }),
    ]),
  }
  match finding.suppression {
    Some(suppression) => {
      properties["suppressionReason"] = Json::string(suppression.reason)
      properties["suppressionExpiresOn"] = Json::string(suppression.expires_on)
      result["suppressions"] = Json::array([
        Json::object({
          "kind": Json::string("external"),
          "status": Json::string("accepted"),
          "justification": Json::string(suppression.reason),
        }),
      ])
    }
    None => ()
  }
  match finding.baseline_status {
    Some(New) => result["baselineState"] = Json::string("new")
    Some(Existing) => result["baselineState"] = Json::string("unchanged")
    Some(Updated) => result["baselineState"] = Json::string("updated")
    None => ()
  }
  result["properties"] = Json::object(properties)
  Json::object(result)
}

///|
fn add_sarif_rule(
  rules : Array[Json],
  seen_rules : Map[String, Bool],
  advisory : Advisory,
) -> Unit {
  if !seen_rules.contains(advisory.id) {
    seen_rules[advisory.id] = true
    let rule : Map[String, Json] = {
      "id": Json::string(advisory.id),
      "shortDescription": Json::object({
        "text": Json::string(advisory.summary),
      }),
      "fullDescription": Json::object({ "text": Json::string(advisory.summary) }),
    }
    for reference in advisory.references {
      if is_sarif_help_reference(reference) {
        rule["helpUri"] = Json::string(reference.trim().to_owned())
        break
      }
    }
    rules.push(Json::object(rule))
  }
}

///|
// Keep optional help links within the ordinary HTTP URI subset. Every original
// reference remains available in result properties, including other URL forms.
fn is_sarif_help_reference(reference : String) -> Bool {
  if !is_http_reference(reference) {
    return false
  }
  let uri = reference.trim().to_owned()
  let rest = uri.split("://").to_array().last().unwrap_or("").to_owned()
  let authority = rest.split("/").to_array()[0].to_owned()
  let authority = authority.split("?").to_array()[0].to_owned()
  let authority = authority.split("#").to_array()[0].to_owned()
  if authority.contains("@") {
    return false
  }
  let chars = uri.to_array()
  let mut fragment = false
  let mut index = 0
  while index < chars.length() {
    match chars[index] {
      '%' => {
        if index + 2 >= chars.length() ||
          !chars[index + 1:index + 3].all(fn(char) {
            match char {
              '0'..<='9' | 'a'..<='f' | 'A'..<='F' => true
              _ => false
            }
          }) {
          return false
        }
        index += 3
        continue
      }
      '#' if !fragment => fragment = true
      'a'..<='z'
      | 'A'..<='Z'
      | '0'..<='9'
      | '-'
      | '.'
      | '_'
      | '~'
      | ':'
      | '/'
      | '?'
      | '@'
      | '!'
      | '$'
      | '&'
      | '\''
      | '('
      | ')'
      | '*'
      | '+'
      | ','
      | ';'
      | '=' => ()
      _ => return false
    }
    index += 1
  }
  true
}

///|
fn match_status_name(status : FindingStatus) -> String {
  match status {
    Affected => "affected"
    NotAffected => "not_affected"
    Uncomparable => "uncomparable"
  }
}

///|
pub fn sbom_json(dependencies : Array[Dependency]) -> String {
  let components = inventory_from_dependencies(dependencies).dependencies.map(fn(
    dependency,
  ) {
    let package_name = percent_encode(dependency.name, allow_slash=true)
    let version = if dependency.version == "" {
      ""
    } else {
      "@\{percent_encode(dependency.version)}"
    }
    Json::object({
      "type": Json::string("library"),
      "name": Json::string(dependency.name),
      "version": Json::string(dependency.version),
      "purl": Json::string("pkg:moonbit/\{package_name}\{version}"),
      "properties": Json::array(dependency_properties(dependency)),
    })
  })
  Json::object({
    "bomFormat": Json::string("CycloneDX"),
    "specVersion": Json::string("1.5"),
    "version": Json::number(1),
    "components": Json::array(components),
  }).stringify(indent=2)
}

///|
fn sarif_artifact_uri(source : String) -> String {
  let path = source.replace_all(old="\\", new="/")
  if path.length() >= 3 && path[1] == ':' && path[2] == '/' {
    "file:///\{path[0:1]}:\{percent_encode(path[2:].to_owned(), allow_slash=true)}"
  } else if path.has_prefix("//") {
    "file:\{percent_encode(path, allow_slash=true)}"
  } else if path.has_prefix("/") {
    "file://\{percent_encode(path, allow_slash=true)}"
  } else {
    percent_encode(path, allow_slash=true)
  }
}

///|
// Escape URI data as UTF-8 octets, preserving path separators only when requested.
fn percent_encode(value : String, allow_slash~ : Bool = false) -> String {
  let result = StringBuilder()
  for char in value {
    match char {
      'a'..<='z' | 'A'..<='Z' | '0'..<='9' | '-' | '.' | '_' | '~' =>
        result.write_char(char)
      '/' if allow_slash => result.write_char(char)
      _ => {
        let codepoint = char.to_int()
        if codepoint < 0x80 {
          write_percent_octet(result, codepoint)
        } else if codepoint < 0x800 {
          write_percent_octet(result, 0xC0 + (codepoint >> 6))
          write_percent_octet(result, 0x80 + (codepoint & 0x3F))
        } else if codepoint < 0x10000 {
          write_percent_octet(result, 0xE0 + (codepoint >> 12))
          write_percent_octet(result, 0x80 + ((codepoint >> 6) & 0x3F))
          write_percent_octet(result, 0x80 + (codepoint & 0x3F))
        } else {
          write_percent_octet(result, 0xF0 + (codepoint >> 18))
          write_percent_octet(result, 0x80 + ((codepoint >> 12) & 0x3F))
          write_percent_octet(result, 0x80 + ((codepoint >> 6) & 0x3F))
          write_percent_octet(result, 0x80 + (codepoint & 0x3F))
        }
      }
    }
  }
  result.to_string()
}

///|
fn write_percent_octet(result : StringBuilder, value : Int32) -> Unit {
  let hex = "0123456789ABCDEF".to_array()
  result.write_char('%')
  result.write_char(hex[value >> 4])
  result.write_char(hex[value & 0x0F])
}

///|
fn dependency_properties(dependency : Dependency) -> Array[Json] {
  let properties : Array[Json] = []
  for alias_name in dependency.aliases {
    properties.push(
      Json::object({
        "name": Json::string("moonvigil:import-alias"),
        "value": Json::string(alias_name),
      }),
    )
  }
  for scope in dependency.scopes {
    properties.push(
      Json::object({
        "name": Json::string("moonvigil:dependency-scope"),
        "value": Json::string(dependency_scope_name(scope)),
      }),
    )
  }
  properties
}

///|
fn sarif_level(severity : String) -> String {
  match severity.to_lower() {
    "critical" | "high" => "error"
    "medium" => "warning"
    _ => "note"
  }
}