///|
priv enum SourceCompiler {
  FileCompiler((Source) -> @ir.IRFile raise)
  UnitCompiler((Source, String, () -> Bool) -> @data.IRUnit raise, @data.Limits)
  ProjectCompiler(
    (ProjectSources, String, () -> Bool) -> @data.IRUnit raise,
    @data.Limits
  )
}

///|
/// One problem of a project compiler. `path` is relative to the source directory; it is empty for a problem of the
/// whole project.
#derive(Debug)
pub(all) struct SourceProblem {
  code : String
  detail : String
  path : String
  location : @ir.SourceLocation?
}

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

///|
/// Source adapters translate their errors without adding a parser dependency to core. A project compiler reports
/// all of its problems with `RejectedSources`.
pub(all) suberror SourceFrontendError {
  Rejected(String, String, @ir.SourceLocation?)
  RejectedSources(Array[SourceProblem])
}

///|
/// A description of a selected source compiler, without reading model bodies.
pub(all) struct SourceFrontendSelection {
  source : String
  unit_id : String
  frontend : String
  language : String
  provider : String
  profile : String?
  default_profile : Bool
  available_profiles : Array[String]
  capabilities : SourceCapabilities?
  evidence : SourceEvidence
  dependencies : Map[String, String]
  output_contract : String
}

///|
/// Unit callbacks receive canonical package/module names and a host-owned logical
/// ID. They run only during execution. Registrations are fixed in each plan.
pub fn Engine::register_unit_frontend(
  self : Engine,
  name : String,
  extensions : Array[String],
  compile : (Source, String, () -> Bool) -> @data.IRUnit raise,
  profile~ : String? = None,
  dependencies~ : Map[String, String] = Map([]),
  limits~ : @data.Limits = @data.Limits::default(),
) -> Unit raise {
  if name == "" ||
    self.frontends.contains(name) ||
    self.source_providers.contains(name) ||
    self.data_formats.contains(name) {
    raise @configuration.Invalid("frontend.registration_conflict: " + name)
  }
  if extensions.is_empty() ||
    !extensions.all(s => {
      s.length() > 1 &&
      s.has_prefix(".") &&
      !s.contains("/") &&
      !s.contains("\\") &&
      !s.contains(":") &&
      !s.contains("\u0000")
    }) {
    raise @configuration.Invalid("frontend.invalid_extensions: " + name)
  }
  if limits.max_bytes <= 0 || limits.max_depth < 0 || limits.max_values <= 0 {
    raise @data.Invalid(
      "data.invalid_limits", "Source unit limits must be positive",
    )
  }
  self.frontends[name] = {
    extensions: extensions.copy(),
    compiler: UnitCompiler(compile, limits),
    profile,
    dependencies: dependencies.copy(),
    provider: name,
    default_profile: true,
    available_profiles: profile.map(p => [p]).unwrap_or([]),
    capabilities: None,
    evidence: Declared,
  }
}

///|
fn Frontend::compile_unit(
  self : Frontend,
  source : Source,
  unit_id : String,
  cancelled : () -> Bool,
) -> @data.IRUnit raise {
  match self.compiler {
    FileCompiler(run) => @data.IRUnit::new(unit_id, run(source))
    UnitCompiler(run, limits) => {
      let canonical = {
        ..source,
        package_name: canonical_path(source.package_name),
        module_name: canonical_path(source.module_name),
      }
      let unit = run(canonical, unit_id, cancelled) catch {
        Rejected(code, detail, location) =>
          raise Rejected(code, detail + self.context(), location)
        error => raise error
      }
      if unit.id != unit_id {
        raise @data.Invalid("data.unit_mismatch", unit_id)
      }
      unit.validate(limits~)
      unit
    }
    ProjectCompiler(_, _) =>
      raise @configuration.Invalid(
        "frontend.project_compiler: " +
        self.provider +
        " compiles whole projects",
      )
  }
}

///|
/// The provider and profile of a provider frontend, for its diagnostics.
fn Frontend::context(self : Frontend) -> String {
  if self.capabilities is Some(_) {
    " [provider " +
    self.provider +
    "; profile " +
    self.profile.unwrap_or("") +
    "]"
  } else {
    ""
  }
}

///|
fn Frontend::compile_project(
  self : Frontend,
  sources : ProjectSources,
  unit_id : String,
  cancelled : () -> Bool,
) -> @data.IRUnit raise {
  guard self.compiler is ProjectCompiler(run, limits) else {
    raise @configuration.Invalid(
      "frontend.project_compiler: " +
      self.provider +
      " compiles one file at a time",
    )
  }
  let context = self.context()
  let unit = run(sources, unit_id, cancelled) catch {
    Rejected(code, detail, location) =>
      raise Rejected(code, detail + context, location)
    // An empty rejection is still a failed compile; it must not pass without a diagnostic.
    RejectedSources([]) =>
      raise Rejected(
        "frontend.rejected",
        "the compiler reported no problems" + context,
        None,
      )
    RejectedSources(problems) =>
      raise RejectedSources(
        problems.map(p => { ..p, detail: p.detail + context, }),
      )
    error => raise error
  }
  if unit.id != unit_id {
    raise @data.Invalid("data.unit_mismatch", unit_id)
  }
  unit.validate(limits~)
  unit
}

///|
pub fn Plan::source_frontends(self : Plan) -> Array[SourceFrontendSelection] {
  let result = []
  for job in self.jobs {
    // Codec-backed inputs already have BoundarySelection descriptors.
    if job.boundary is Some(b) && b.input is Some(_) {
      continue
    }
    if job.frontend is Some(frontend) {
      for source in job.paths {
        result.push({
          source,
          unit_id: match job.project {
            Some(project) => project.unit_id
            None =>
              stem(
                if job.source_root == "" {
                  source
                } else {
                  source[job.source_root.length() + 1:].to_owned()
                },
              )
          },
          frontend: job.settings.frontend,
          language: job.settings.frontend,
          provider: frontend.provider,
          profile: frontend.profile,
          default_profile: frontend.default_profile,
          available_profiles: frontend.available_profiles.copy(),
          capabilities: frontend.capabilities.map(SourceCapabilities::snapshot),
          evidence: frontend.evidence,
          dependencies: frontend.dependencies.copy(),
          output_contract: match frontend.compiler {
            FileCompiler(_) => "ir-file"
            UnitCompiler(_, _) => "ir-unit"
            ProjectCompiler(_, _) => "ir-project"
          },
        })
      }
    }
  }
  result
}