///|
/// Shared result of one offline release-quality analysis.
pub(all) struct AnalysisContext {
  config : DoctorConfig
  inventory : ProjectInventory
  manifest : MoonManifest?
  package_graph : PackageGraph
} derive(Debug, Eq)

///|
pub fn AnalysisContext::new(
  config : DoctorConfig,
  inventory : ProjectInventory,
  manifest : MoonManifest?,
  package_graph : PackageGraph,
) -> AnalysisContext {
  { config, inventory, manifest, package_graph }
}

///|
pub fn AnalysisContext::snapshot(self : AnalysisContext) -> ProjectSnapshot {
  self.inventory.to_snapshot()
}

///|
fn rule_location(
  context : AnalysisContext,
  path : String,
  needle : String,
) -> SourceLocation {
  match context.inventory.find_file(path) {
    Some(file) =>
      match file.location_of(needle) {
        Some(location) => location
        None => SourceLocation::new(path, 1, 1)
      }
    None => SourceLocation::new(path, 1, 1)
  }
}

///|
fn catalog_diagnostic(
  context : AnalysisContext,
  rule_id : String,
  path : String,
  needle : String,
  detail : String,
) -> Diagnostic {
  match find_rule_definition(rule_id) {
    Some(rule) =>
      Diagnostic::at(
        context.config.severity_for(rule.id, rule.default_severity),
        rule.id,
        rule.title,
        detail,
        rule_location(context, path, needle),
        [rule.remediation],
      )
    None => Diagnostic::new(Warn, rule_id, "Unknown Moon Doctor rule", detail)
  }
}

///|
fn add_if_enabled(
  diagnostics : Array[Diagnostic],
  context : AnalysisContext,
  rule_id : String,
  path : String,
  needle : String,
  detail : String,
) -> Unit {
  if !context.config.is_rule_disabled(rule_id) {
    diagnostics.push(catalog_diagnostic(context, rule_id, path, needle, detail))
  }
}

///|
fn analyze_package_structure(
  context : AnalysisContext,
  diagnostics : Array[Diagnostic],
) -> Unit {
  for node in context.package_graph.packages {
    if !node.has_source() {
      add_if_enabled(
        diagnostics,
        context,
        "package_missing_source",
        if node.path.length() == 0 {
          "moon.pkg"
        } else {
          node.path + "/moon.pkg"
        },
        "",
        "Package " + node.import_path + " has no non-test .mbt source file.",
      )
    }
    if !node.has_tests() {
      add_if_enabled(
        diagnostics,
        context,
        "package_missing_tests",
        if node.path.length() == 0 {
          "moon.pkg"
        } else {
          node.path + "/moon.pkg"
        },
        "",
        "Package " + node.import_path + " has no package-local test file.",
      )
    }
  }
  for _, pair in context.package_graph.missing_local_imports {
    let (source, target) = pair
    add_if_enabled(
      diagnostics,
      context,
      "package_missing_local_import",
      "moon.pkg",
      target,
      "Package " +
      source +
      " imports local package " +
      target +
      " but no matching moon.pkg was discovered.",
    )
  }
  for cycle in context.package_graph.cycles {
    add_if_enabled(
      diagnostics,
      context,
      "package_cycle",
      "moon.pkg",
      cycle[0],
      "Local package cycle: " + cycle.join(" -> "),
    )
  }
}

///|
fn analyze_release_metadata(
  context : AnalysisContext,
  diagnostics : Array[Diagnostic],
) -> Unit {
  if context.config.profile == Mooncakes || context.config.profile == Release {
    match context.manifest {
      Some(manifest) => {
        if manifest.text("version") is None {
          add_if_enabled(
            diagnostics, context, "missing_version", "moon.mod", "version", "Mooncakes and release profiles require a version.",
          )
        }
        if manifest.text("description") is None {
          add_if_enabled(
            diagnostics, context, "missing_description", "moon.mod", "description",
            "Published packages need a concise description.",
          )
        }
        if manifest.text_array("keywords").length() == 0 {
          add_if_enabled(
            diagnostics, context, "missing_keywords", "moon.mod", "keywords", "Published packages need searchable keywords.",
          )
        }
      }
      None => ()
    }
  }
  if context.config.profile == Github || context.config.profile == Release {
    if !context.inventory.has_file(".github/workflows/ci.yml") &&
      !context.inventory.has_file(".github/workflows/ci.yaml") {
      add_if_enabled(
        diagnostics, context, "missing_ci", ".github/workflows", "", "GitHub/release profile expects a CI workflow.",
      )
    }
  }
}

///|
fn normalize_existing_diagnostics(
  diagnostics : Array[Diagnostic],
  context : AnalysisContext,
) -> Array[Diagnostic] {
  let normalized : Array[Diagnostic] = []
  for diagnostic in diagnostics {
    if context.config.is_rule_disabled(diagnostic.code) {
      continue
    }
    match find_rule_definition(diagnostic.code) {
      Some(rule) => {
        let location = match diagnostic.location {
          Some(value) => value
          None => rule_location(context, "moon.mod", diagnostic.code)
        }
        normalized.push(
          Diagnostic::at(
            context.config.severity_for(rule.id, diagnostic.severity),
            diagnostic.code,
            diagnostic.message,
            diagnostic.detail,
            location,
            if diagnostic.remediation.length() == 0 {
              [rule.remediation]
            } else {
              diagnostic.remediation
            },
          ),
        )
      }
      None => normalized.push(diagnostic)
    }
  }
  normalized
}

///|
/// Run the full recursive inventory, package graph, policy and legacy checks.
pub fn analyze_project(
  root : String,
  config : DoctorConfig,
) -> DoctorReport raise {
  let inventory = scan_project(root, config)
  let manifest = parse_root_manifest(inventory)
  let package_graph = build_package_graph(inventory)
  let context = AnalysisContext::new(config, inventory, manifest, package_graph)
  let baseline = diagnose_with_config(context.snapshot(), config)
  let diagnostics = baseline.diagnostics
  analyze_package_structure(context, diagnostics)
  analyze_release_metadata(context, diagnostics)
  DoctorReport::with_context(
    root,
    normalize_existing_diagnostics(diagnostics, context),
    config.profile.label(),
    config.release_checklist(),
  )
}