///|
/// Scenario 3: the upgrade shortlist.
///
/// "Which of these candidates is worth a test run?" is a decision a tool makes
/// constantly, and the only part of it this library can decide is the version
/// part. This module decides that part and keeps the rest out:
///
/// ```moonbit nocheck
/// let plan = upgrade_shortlist(current, Some(target), None, Some(python), candidates)
/// plan.shortlist   // ascending: the smallest step first
/// plan.dropped     // every other candidate with the rule that dropped it
/// ```
///
/// The rules, **in the order they are applied**, because the first one that fires
/// is the reason the caller is told:
///
/// 1. the candidate's version does not parse -> `Unparsable`. This is checked
///    first because there is nothing to compare otherwise;
/// 2. it is equal to the current version -> `Same`. Equal is decided by
///    `Version::compare`, so `1.5` and `1.5.0` are the same version;
/// 3. it is lower -> `Downgrade`;
/// 4. it is outside the target range -> `OutOfRange`;
/// 5. it is inside the range but the prerelease policy holds it back ->
///    `Prerelease`. The policy is `SpecifierSet::filter`'s, so `None` (the
///    automatic policy) admits prereleases **only when the range admits nothing
///    else** -- and that fallback is computed over the candidates that are newer
///    and inside the range, not over the whole input. The policy reaches the
///    candidates **through the range**: with no target range there is nothing to
///    filter, so `Some(false)` has nothing to do and prereleases are kept. That
///    is the same choice `filter` makes (a `SpecifierSet` with no clauses does
///    not filter), and it is pinned by a unit test and by the corpus;
/// 6. its `requires-python` excludes the target interpreter ->
///    `RequiresPython`. A `requires-python` that does not parse is ignored rather
///    than treated as a rejection, which is what pip does and what
///    `index.mbt`'s resolver already does;
/// 7. otherwise it is kept.
///
/// The shortlist is ordered ascending, ties broken by the caller's spelling, so
/// two spellings of one version stay two entries and the order is stable. The
/// dropped list keeps the order the rules ran in, and within one rule the order
/// the caller listed the candidates.
///
/// **What this does not answer.** A version on the shortlist satisfies a
/// constraint; that is a necessary condition, not a reason to upgrade. API
/// compatibility, dependency solvability, security advisories and whether the
/// test suite passes are all outside this module, and a caller that presents the
/// shortlist as a recommendation is misusing it. `examples/upgrade-check` prints
/// that sentence in its own output rather than leaving it in a comment.

///|
/// One candidate to consider.
///
/// `version` is `None` when the caller's spelling is not a PEP 440 version at
/// all: the candidate is then reported as `Unparsable` instead of being dropped
/// from the list, so a report can account for every input it was given.
pub(all) struct UpgradeCandidate {
  raw : String
  version : Version?
  requires_python : String?
} derive(Eq, @debug.Debug)

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

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

///|
/// Parse a candidate's version, and remember the `requires-python` it declares
/// (if any) as text: this module compares it against the target interpreter, and
/// the comparison is the only thing it needs it for.
///
/// ```moonbit nocheck
/// let c = UpgradeCandidate::parse("1.2.3", Some(">=3.8"))
/// c.version.unwrap().to_string()   // "1.2.3"
/// ```
pub fn UpgradeCandidate::parse(
  raw : String,
  requires_python : String?,
) -> UpgradeCandidate {
  let version = if raw.is_empty() { None } else { parse_version_opt(raw) }
  { raw, version, requires_python, }
}

///|
/// The rule that dropped a candidate. Closed on purpose: a report that says
/// "rejected" without saying why is not actionable, and the differential corpus
/// asserts the exact reason for each candidate.
pub(all) enum UpgradeRejection {
  Unparsable
  Same
  Downgrade
  OutOfRange
  Prerelease
  RequiresPython
} derive(Eq, @debug.Debug)

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

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

///|
/// The stable spelling of a rejection rule, for reports and for the corpus.
pub fn UpgradeRejection::code(self : UpgradeRejection) -> String {
  match self {
    Unparsable => "unparsable"
    Same => "same"
    Downgrade => "downgrade"
    OutOfRange => "out-of-range"
    Prerelease => "prerelease"
    RequiresPython => "requires-python"
  }
}

///|
/// The result: what to test, and what was dropped with the reason.
pub(all) struct UpgradePlan {
  shortlist : Array[UpgradeCandidate]
  dropped : Array[(UpgradeCandidate, UpgradeRejection)]
} derive(@debug.Debug)

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

///|
/// Decide the version part of an upgrade. See the module documentation for the
/// rule order; every input candidate appears exactly once in the result, either
/// in `shortlist` or in `dropped`.
pub fn upgrade_shortlist(
  current : Version,
  target : SpecifierSet?,
  prereleases : Bool?,
  python : Version?,
  candidates : Array[UpgradeCandidate],
) -> UpgradePlan {
  let newer : Array[UpgradeCandidate] = []
  let dropped : Array[(UpgradeCandidate, UpgradeRejection)] = []
  // Rules 1 to 3, in the caller's order.
  for candidate in candidates {
    match candidate.version {
      None => dropped.push((candidate, Unparsable))
      Some(version) => {
        let order = Version::compare(version, current)
        if order == 0 {
          dropped.push((candidate, Same))
        } else if order < 0 {
          dropped.push((candidate, Downgrade))
        } else {
          newer.push(candidate)
        }
      }
    }
  }
  // Rules 4 and 5 together: the range and the policy are one question, and
  // `SpecifierSet::filter` is the function that answers it -- including the
  // "admit prereleases only when nothing else survives" fallback. Reusing it
  // means this module cannot disagree with `filter` about what `auto` means.
  let newer_versions : Array[Version] = []
  for candidate in newer {
    match candidate.version {
      Some(version) => newer_versions.push(version)
      None => ()
    }
  }
  let admitted : Array[Bool] = []
  for _version in newer_versions {
    admitted.push(false)
  }
  match target {
    Some(spec) => {
      let kept = SpecifierSet::filter(newer_versions, spec, prereleases~)
      for version in kept {
        // Match by value and take the first unconsumed slot, so two spellings of
        // the same version are both admitted rather than one being dropped.
        for i = 0; i < newer_versions.length(); i = i + 1 {
          if !admitted[i] && Version::compare(newer_versions[i], version) == 0 {
            admitted[i] = true
            break
          }
        }
      }
    }
    None =>
      for i = 0; i < admitted.length(); i = i + 1 {
        admitted[i] = true
      }
  }
  let inside : Array[UpgradeCandidate] = []
  for i = 0; i < newer.length(); i = i + 1 {
    let candidate = newer[i]
    if admitted[i] {
      inside.push(candidate)
    } else if target is Some(spec) {
      // Inside the range and held back by the policy is a different answer from
      // outside the range, and the report keeps them apart.
      let contained = match candidate.version {
        Some(version) => spec.contains(version, prereleases=Some(true))
        None => false
      }
      if contained {
        dropped.push((candidate, Prerelease))
      } else {
        dropped.push((candidate, OutOfRange))
      }
    } else {
      dropped.push((candidate, OutOfRange))
    }
  }
  // Rule 6: `requires-python`. An unreadable value is ignored, not rejected.
  let kept : Array[UpgradeCandidate] = []
  for candidate in inside {
    let mut allowed = true
    match (candidate.requires_python, python) {
      (Some(text), Some(python_version)) =>
        if text != "" {
          match parse_requires_python(text) {
            Some(spec) =>
              allowed = spec.contains(python_version, prereleases=Some(true))
            None => ()
          }
        }
      _ => ()
    }
    if allowed {
      kept.push(candidate)
    } else {
      dropped.push((candidate, RequiresPython))
    }
  }
  // Ascending: the smallest step is the one to test first. The raw spelling
  // breaks ties so the order is stable when two candidates are one version.
  // A kept candidate always has a version, so the comparator below pairs each
  // one with it once instead of re-deciding that inside the loop.
  let ordered : Array[(Version, UpgradeCandidate)] = []
  for candidate in kept {
    match candidate.version {
      Some(version) => ordered.push((version, candidate))
      None => ()
    }
  }
  ordered.sort_by(fn(a, b) {
    let order = Version::compare(a.0, b.0)
    if order != 0 {
      order
    } else {
      String::lexical_compare(a.1.raw, b.1.raw)
    }
  })
  let shortlist : Array[UpgradeCandidate] = []
  for entry in ordered {
    shortlist.push(entry.1)
  }
  { shortlist, dropped, }
}

///|
/// A candidate's `requires-python`, or `None` when it does not parse -- the
/// caller is told nothing and the candidate stays in the shortlist, which is what
/// pip does with a `Requires-Python` it cannot read.
fn parse_requires_python(text : String) -> SpecifierSet? {
  Some(SpecifierSet::parse(text)) catch {
    _ => None
  }
}

///|
/// The shortlist as the versions to try, most useful first. A convenience for a
/// caller that only needs the versions and not the spellings.
pub fn UpgradePlan::versions(self : UpgradePlan) -> Array[Version] {
  let out : Array[Version] = []
  for candidate in self.shortlist {
    match candidate.version {
      Some(version) => out.push(version)
      None => ()
    }
  }
  out
}

///|
/// Whether the plan found anything at all. A tool that prints "nothing to test"
/// should ask this rather than check `shortlist().length()`, because the two
/// cases it distinguishes -- "no candidate is newer" and "no candidate was
/// given" -- read differently to a user.
pub fn UpgradePlan::is_empty(self : UpgradePlan) -> Bool {
  self.shortlist.is_empty()
}

///|
/// One line per dropped candidate, in the order the rules ran, for a report.
pub fn UpgradePlan::rejection_summary(self : UpgradePlan) -> String {
  let parts : Array[String] = []
  for entry in self.dropped {
    parts.push("\{entry.0.raw}=\{entry.1.code()}")
  }
  parts.join(";")
}