///|
/// A portable baseline key. It deliberately contains no project paths.
pub struct BaselineEntry {
  advisory_id : String
  package_name : String
  version : String
  affected : String?
  severity : String?
  fixed_version : String?
} derive(Debug, Eq, ToJson, FromJson)

///|
/// A validated, versioned snapshot of affected direct dependencies.
pub struct ScanBaseline {
  schema_version : String
  entries : Array[BaselineEntry]
} derive(Debug, Eq, ToJson, FromJson)

///|
/// Whether an affected finding is new or already present in the supplied snapshot.
pub enum BaselineFindingStatus {
  New
  Existing
  Updated
} derive(Debug, Eq, ToJson)

///|
/// Summary of a baseline comparison, including keys no longer observed.
pub struct BaselineSummary {
  snapshot_entries : Int32
  new_findings : Int32
  existing_findings : Int32
  updated_findings : Int32
  legacy_snapshot : Bool
  no_longer_detected : Array[BaselineEntry]
} derive(Debug, Eq, ToJson)

///|
pub(all) suberror BaselineValidationError {
  UnsupportedBaselineSchemaVersion(String)
  InvalidBaselineField(field~ : String, reason~ : String)
  InvalidBaselineEntry(index~ : Int32, field~ : String, reason~ : String)
  DuplicateBaselineEntry(index~ : Int32, advisory_id~ : String)
} derive(Debug, Eq)

///|
/// Parse and strictly validate a schema-v1 or schema-v2 baseline snapshot.
pub fn parse_baseline(text : String) -> ScanBaseline raise {
  let json = @json.parse(text)
  validate_baseline_shape(json)
  let baseline : ScanBaseline = @json.from_json(json)
  validate_baseline(baseline)
  baseline
}

///|
/// Validate a baseline created by a caller or deserializer.
pub fn validate_baseline(baseline : ScanBaseline) -> Unit raise {
  if baseline.schema_version != "1" && baseline.schema_version != "2" {
    raise BaselineValidationError::UnsupportedBaselineSchemaVersion(
      baseline.schema_version,
    )
  }
  for index, entry in baseline.entries {
    if entry.advisory_id.trim() == "" {
      raise BaselineValidationError::InvalidBaselineEntry(
        index~,
        field="advisory_id",
        reason="must not be empty",
      )
    }
    if entry.package_name.trim() == "" {
      raise BaselineValidationError::InvalidBaselineEntry(
        index~,
        field="package_name",
        reason="must not be empty",
      )
    }
    if !is_stable_version(entry.version) {
      raise BaselineValidationError::InvalidBaselineEntry(
        index~,
        field="version",
        reason="must be a stable three-part version",
      )
    }
    if baseline.schema_version == "1" {
      if entry.affected is Some(_) ||
        entry.severity is Some(_) ||
        entry.fixed_version is Some(_) {
        raise BaselineValidationError::InvalidBaselineEntry(
          index~,
          field="risk_evidence",
          reason="is not supported by schema version 1",
        )
      }
    } else {
      match entry.affected {
        Some(affected) if is_supported_affected_range(affected) => ()
        Some(_) =>
          raise BaselineValidationError::InvalidBaselineEntry(
            index~,
            field="affected",
            reason="must use the supported stable three-part range syntax",
          )
        None =>
          raise BaselineValidationError::InvalidBaselineEntry(
            index~,
            field="affected",
            reason="is required by schema version 2",
          )
      }
      match entry.severity {
        Some(severity) if is_policy_severity(severity) => ()
        Some(_) =>
          raise BaselineValidationError::InvalidBaselineEntry(
            index~,
            field="severity",
            reason="must be one of critical, high, medium, low, or info",
          )
        None =>
          raise BaselineValidationError::InvalidBaselineEntry(
            index~,
            field="severity",
            reason="is required by schema version 2",
          )
      }
      match entry.fixed_version {
        Some(fixed_version) if fixed_version == "" ||
          is_stable_version(fixed_version) => ()
        Some(_) =>
          raise BaselineValidationError::InvalidBaselineEntry(
            index~,
            field="fixed_version",
            reason="must be empty or a stable three-part version",
          )
        None =>
          raise BaselineValidationError::InvalidBaselineEntry(
            index~,
            field="fixed_version",
            reason="is required by schema version 2",
          )
      }
    }
    for previous_index in 0.. ScanBaseline {
  let entries : Array[BaselineEntry] = []
  for finding in report.findings {
    if finding.status is Affected {
      let entry = {
        advisory_id: finding.advisory.id,
        package_name: finding.dependency.name,
        version: finding.dependency.version,
        affected: Some(finding.advisory.affected),
        severity: Some(finding.advisory.severity),
        fixed_version: Some(finding.advisory.fixed_version),
      }
      if !entries.any(fn(existing) { baseline_entry_equal(existing, entry) }) {
        entries.push(entry)
      }
    }
  }
  entries.sort_by(baseline_entry_compare)
  { schema_version: "2", entries, }
}

///|
/// Serialize a validated snapshot as stable, indented JSON.
pub fn baseline_json(baseline : ScanBaseline) -> String raise {
  validate_baseline(baseline)
  let entries = baseline.entries.map(fn(entry) {
    let fields : Map[String, Json] = {
      "advisory_id": Json::string(entry.advisory_id),
      "package_name": Json::string(entry.package_name),
      "version": Json::string(entry.version),
    }
    if baseline.schema_version == "2" {
      fields["affected"] = Json::string(entry.affected.unwrap())
      fields["severity"] = Json::string(entry.severity.unwrap())
      fields["fixed_version"] = Json::string(entry.fixed_version.unwrap())
    }
    Json::object(fields)
  })
  Json::object({
    "schema_version": Json::string(baseline.schema_version),
    "entries": Json::array(entries),
  }).stringify(indent=2)
}

///|
/// Mark findings against a validated snapshot and report snapshot keys no
/// longer present in the current affected findings.
pub fn apply_baseline(
  report : ScanReport,
  baseline : ScanBaseline,
) -> ScanReport raise {
  validate_baseline(baseline)
  let matched : Array[Bool] = baseline.entries.map(fn(_) { false })
  let findings : Array[Finding] = []
  let mut new_findings = 0
  let mut existing_findings = 0
  let mut updated_findings = 0
  for finding in report.findings {
    let mut existing_index : Int32? = None
    for index, entry in baseline.entries {
      if baseline_entry_matches_finding(entry, finding) {
        existing_index = Some(index)
      }
    }
    let baseline_status = match existing_index {
      Some(index) => {
        matched[index] = true
        if baseline_entry_risk_matches_finding(baseline.entries[index], finding) {
          existing_findings += 1
          Existing
        } else {
          updated_findings += 1
          Updated
        }
      }
      None => {
        new_findings += 1
        New
      }
    }
    findings.push({
      dependency: finding.dependency,
      advisory: finding.advisory,
      status: finding.status,
      suppression: finding.suppression,
      baseline_status: Some(baseline_status),
    })
  }
  let no_longer_detected : Array[BaselineEntry] = []
  for index, entry in baseline.entries {
    if !matched[index] {
      no_longer_detected.push(entry)
    }
  }
  no_longer_detected.sort_by(baseline_entry_compare)
  {
    schema_version: "6",
    dependencies: report.dependencies,
    findings,
    uncomparable: report.uncomparable,
    conflicts: report.conflicts,
    policy: report.policy,
    baseline: Some({
      snapshot_entries: baseline.entries.length(),
      new_findings,
      existing_findings,
      updated_findings,
      legacy_snapshot: baseline.schema_version == "1",
      no_longer_detected,
    }),
  }
}

///|
fn validate_baseline_shape(json : Json) -> Unit raise {
  match json {
    Object(root) => {
      match root.get("schema_version") {
        Some(String(_)) => ()
        None =>
          raise BaselineValidationError::InvalidBaselineField(
            field="schema_version",
            reason="is required",
          )
        _ =>
          raise BaselineValidationError::InvalidBaselineField(
            field="schema_version",
            reason="must be a string",
          )
      }
      match root.get("entries") {
        Some(Array(entries)) =>
          for index, entry in entries {
            match entry {
              Object(fields) => {
                let schema_version = match root.get("schema_version") {
                  Some(String(value)) => value
                  _ => ""
                }
                let required_fields = if schema_version == "2" {
                  [
                    "advisory_id", "package_name", "version", "affected", "severity",
                    "fixed_version",
                  ]
                } else {
                  ["advisory_id", "package_name", "version"]
                }
                for field in required_fields {
                  match fields.get(field) {
                    Some(String(_)) => ()
                    None =>
                      raise BaselineValidationError::InvalidBaselineEntry(
                        index~,
                        field~,
                        reason="is required",
                      )
                    _ =>
                      raise BaselineValidationError::InvalidBaselineEntry(
                        index~,
                        field~,
                        reason="must be a string",
                      )
                  }
                }
              }
              _ =>
                raise BaselineValidationError::InvalidBaselineEntry(
                  index~,
                  field="$",
                  reason="must be an object",
                )
            }
          }
        None =>
          raise BaselineValidationError::InvalidBaselineField(
            field="entries",
            reason="is required and must be an array",
          )
        _ =>
          raise BaselineValidationError::InvalidBaselineField(
            field="entries",
            reason="must be an array",
          )
      }
    }
    _ =>
      raise BaselineValidationError::InvalidBaselineField(
        field="$",
        reason="must be a JSON object",
      )
  }
}

///|
fn baseline_entry_equal(left : BaselineEntry, right : BaselineEntry) -> Bool {
  left.advisory_id == right.advisory_id &&
  left.package_name == right.package_name &&
  left.version == right.version
}

///|
fn baseline_entry_compare(left : BaselineEntry, right : BaselineEntry) -> Int32 {
  let advisory = lexicographic_compare(left.advisory_id, right.advisory_id)
  if advisory != 0 {
    return advisory
  }
  let package_order = lexicographic_compare(
    left.package_name,
    right.package_name,
  )
  if package_order != 0 {
    return package_order
  }
  lexicographic_compare(left.version, right.version)
}

///|
fn baseline_entry_matches_finding(
  entry : BaselineEntry,
  finding : Finding,
) -> Bool {
  entry.advisory_id == finding.advisory.id &&
  entry.package_name == finding.dependency.name &&
  entry.version == finding.dependency.version
}

///|
fn baseline_entry_risk_matches_finding(
  entry : BaselineEntry,
  finding : Finding,
) -> Bool {
  match (entry.affected, entry.severity, entry.fixed_version) {
    (Some(affected), Some(severity), Some(fixed_version)) =>
      affected == finding.advisory.affected &&
      severity == finding.advisory.severity &&
      fixed_version == finding.advisory.fixed_version
    _ => true
  }
}