///|
fn ion_record(fields : Array[(String, @ion.IonValue)]) -> @ion.IonValue {
  @ion.Ion(
    @ion.IonValueKind::Struct(fields.map(pair => @ion.field(pair.0, pair.1))),
  )
}

///|
fn origin(name : String, loc : @location.Location) -> @ion.IonValue {
  ion_record([
    ("name", @ion.IonValue::string(name)),
    ("coordinate_profile", @ion.IonValue::string("moonbit-parser-position-v1")),
    ("start_line", @ion.IonValue::int(loc.start.lnum)),
    ("start_column", @ion.IonValue::int(loc.start.cnum - loc.start.bol)),
    ("end_line", @ion.IonValue::int(loc.end.lnum)),
    ("end_column", @ion.IonValue::int(loc.end.cnum - loc.end.bol)),
  ])
}

///|
fn logical_path(text : String) -> @ir.Path raise FrontendError {
  try {
    let path = @ir.Path::parse(text)
    if text == "" || text.length() > 256 || path.to_canonical() != text {
      raise Invalid({
        code: "moonbit_frontend.invalid_identity",
        detail: "Package and module paths must be bounded and canonical",
        source: None,
      })
    }
    path
  } catch {
    Invalid(_) as error => raise error
    _ =>
      raise Invalid({
        code: "moonbit_frontend.invalid_identity",
        detail: "Invalid package or module path",
        source: None,
      })
  }
}

///|
/// Compile one pure, explicitly annotated Boolean function. No host access.
pub fn compile(
  input : Input,
  limits~ : Limits = Limits::default(),
  strict_naming~ : Bool = false,
  cancelled~ : () -> Bool = fn() { false },
  scope~ : @observe.Scope = @observe.Observer::noop().scope(),
) -> Output raise {
  scope.observe(
    @observe.Frontend,
    fn(scope) raise {
      let output = compile_scoped(input, limits, cancelled, scope)
      check_naming_warnings(output.warnings, strict_naming)
      output
    },
    is_cancelled=frontend_cancelled,
  )
}

///|
fn frontend_cancelled(error : Error) -> Bool {
  error is Invalid(diagnostic) &&
  diagnostic.code == "moonbit_frontend.cancelled"
}

///|
fn compile_scoped(
  input : Input,
  limits : Limits,
  cancelled : () -> Bool,
  scope : @observe.Scope,
) -> Output raise {
  fn input_error(code : String, detail : String) -> Unit raise FrontendError {
    raise Invalid({ code: "moonbit_frontend." + code, detail, source: None, })
  }
  limits.validate()
  if input.unit_id == "" ||
    input.unit_id.length() > 256 ||
    input.unit_id.to_array().any(c => c.to_int() < 32 || c.to_int() == 127) {
    input_error(
      "invalid_identity", "A bounded logical source identity is required",
    )
  }
  if cancelled() {
    input_error("cancelled", "Frontend cancelled")
  }
  if @utf8.encode(input.source).length() > limits.max_bytes {
    input_error("byte_limit", "Source exceeds the byte budget")
  }
  let package_name = logical_path(input.package_name)
  let module_name = logical_path(input.module_name)
  let nodes = scope.observe(
    @observe.Parse,
    fn(_) raise {
      check_lexing(input)
      let (nodes, reports) = @parser.parse_string(
        input.source,
        name=input.unit_id,
      )
      if cancelled() {
        input_error("cancelled", "Frontend cancelled")
      }
      if !reports.is_empty() {
        reject("syntax", reports[0].msg, reports[0].loc)
      }
      nodes
    },
    is_cancelled=frontend_cancelled,
  )
  let (decl, expr, lowerer, inputs, origins, public) = scope.observe(
    @observe.ProfileCheck,
    fn(_) raise {
      if nodes.length() != 1 {
        input_error(
          "declaration_count", "The first profile requires exactly one function",
        )
      }
      let node = nodes.head().unwrap()
      guard node
        is @ast.TopFuncDef(fun_decl=decl, decl_body=body, where_clause=None, ..) else {
        reject(
          "unsupported_declaration",
          "Only a top-level function is supported",
          node.loc(),
        )
        abort("unreachable")
      }
      if decl.type_name is Some(_) ||
        decl.is_async is Some(_) ||
        decl.nocancel_ is Some(_) ||
        !decl.quantifiers.is_empty() ||
        !decl.attrs.is_empty() ||
        !(decl.error_type is @ast.NoErrorType) {
        reject(
          "unsupported_function",
          "Methods, generics, attributes and effects are unsupported",
          decl.loc,
        )
      }
      let public = match decl.vis {
        @ast.Pub(attr=None, ..) => true
        @ast.Default => false
        _ => {
          reject(
            "unsupported_visibility",
            "Only default or plain pub function visibility is supported",
            decl.loc,
          )
          abort("unreachable")
        }
      }
      require_bool(decl.return_type, decl.loc)
      guard decl.decl_params is Some(parameters) else {
        reject(
          "unsupported_parameters",
          "An explicit positional parameter list is required",
          decl.loc,
        )
        abort("unreachable")
      }
      if parameters.length() > limits.max_parameters {
        reject("parameter_limit", "Parameter budget exceeded", decl.params_loc)
      }
      let parameter_names = parameters.map(p => {
        match p {
          @ast.Positional(binder~, ..) => (binder.name, span(binder.loc))
          _ => {
            reject(
              "unsupported_parameters",
              "Only named positional parameters are supported",
              p.loc(),
            )
            abort("unreachable")
          }
        }
      })
      let names = BinderNames::new(
        parameter_names.iter().collect(),
        "binding:" + decl.name.name,
      )
      let boolean = boolean_type()
      let lowerer = Lowerer::{
        scope: Map([]),
        boolean,
        limits,
        cancelled,
        expressions: 0,
        names,
        origins: [],
      }
      let inputs = []
      let origins = []
      for parameter in parameters {
        guard parameter is @ast.Positional(binder~, ty~) else {
          reject(
            "unsupported_parameters",
            "Only named positional parameters are supported",
            parameter.loc(),
          )
          abort("unreachable")
        }
        require_bool(ty, binder.loc)
        if lowerer.scope.contains(binder.name) {
          reject(
            "duplicate_parameter",
            "Duplicate parameter: " + binder.name,
            binder.loc,
          )
        }
        let name = lowerer.names.bind(binder.name)
        lowerer.scope[binder.name] = name
        inputs.push((name, boolean))
        origins.push(
          allocated_origin(
            origin(binder.name, binder.loc),
            name,
            lowerer.names.source.reason(binder.name),
          ),
        )
      }
      guard body is @ast.DeclBody(expr~) else {
        reject(
          "missing_body",
          "An ordinary expression body is required",
          decl.loc,
        )
        abort("unreachable")
      }
      (decl, expr, lowerer, inputs, origins, public)
    },
    is_cancelled=frontend_cancelled,
  )
  scope.observe(
    @observe.Lower,
    fn(_) raise {
      let boolean = lowerer.boolean
      let lowered = lowerer.lower(expr, 0)
      let allocated = allocate_source_names(
        [(decl.name.name, span(decl.name.loc))],
        package_name.to_canonical() + ":" + module_name.to_canonical(),
      )
      let name = allocated.names[decl.name.name]
      let warnings = allocated.warnings + lowerer.names.source.warnings
      let entry = @ir.FQName::parse(
        package_name.to_canonical() +
        ":" +
        module_name.to_canonical() +
        "#" +
        name.to_canonical(),
      )
      let module_def : @ir.ModuleDefinition = {
        types: Map([]),
        doc: None,
        values: Map::from_array([
          (
            name,
            {
              access: if public {
                @ir.Access::Public
              } else {
                @ir.Access::Private
              },
              value: {
                doc: source_documentation(decl.doc),
                value: @ir.ValueDefinition::ExpressionBody(
                  inputs, boolean, lowered,
                ),
              },
            },
          ),
        ]),
      }
      let ir = @ir.IRFile::new(
        @ir.Distribution::Library(package_name, Map([]), {
          modules: Map::from_array([
            (module_name, { access: @ir.Access::Public, value: module_def, }),
          ]),
        }),
      )
      let id = "value:" + entry.to_canonical()
      {
        unit_id: input.unit_id,
        ir,
        entry,
        public,
        metadata: [
          {
            node_id: id,
            data: ion_record([
              ("declaration", origin(decl.name.name, decl.name.loc)),
              (
                "naming",
                ion_record([
                  ("policy", @ion.IonValue::string(NAMING_POLICY)),
                  ("original", @ion.IonValue::string(decl.name.name)),
                  (
                    "allocation_reason",
                    @ion.IonValue::string(allocated.reason(decl.name.name)),
                  ),
                  ("identity", @ion.IonValue::string(name.to_canonical())),
                ]),
              ),
              ("parameters", @ion.IonValue::list(origins)),
            ]),
          },
          {
            node_id: id + "/body",
            data: ion_record([
              ("body", origin(decl.name.name, expr.loc())),
              ("nodes", @ion.IonValue::list(lowerer.origins.copy())),
            ]),
          },
        ],
        annotations: [@ion.symbol("morphir_pipeline")],
        warnings,
        provenance: [
          ion_record([
            ("frontend", @ion.IonValue::string(LEGACY_BOOL_PROFILE)),
            ("parser_version", @ion.IonValue::string(PARSER_VERSION)),
            ("naming_policy", @ion.IonValue::string(NAMING_POLICY)),
            ("unit_id", @ion.IonValue::string(input.unit_id)),
          ]).with_annotation(@ion.symbol("morphir_moonbit_frontend")),
        ] +
        warnings.map(w => w.to_ion()),
      }
    },
    is_cancelled=frontend_cancelled,
  )
}

///|
/// Check budgets without parsing a source document.
pub fn Limits::validate(self : Limits) -> Unit raise FrontendError {
  if self.max_bytes <= 0 ||
    self.max_bytes > 65536 ||
    self.max_parameters < 0 ||
    self.max_parameters > 16 ||
    self.max_depth < 0 ||
    self.max_depth > 64 ||
    self.max_expressions <= 0 ||
    self.max_expressions > 4096 {
    raise Invalid({
      code: "moonbit_frontend.invalid_limits",
      detail: "Limits must stay within the supported profile budgets",
      source: None,
    })
  }
}

///|
/// Extend origin receipts without moving the fields consumed by source-label readers.
fn allocated_origin(
  original : @ion.IonValue,
  name : @ir.Name,
  reason : String,
) -> @ion.IonValue {
  guard original.kind() is Struct(fields) else {
    abort("Expected origin record")
  }
  @ion.Ion(
    @ion.IonValueKind::Struct(
      fields +
      [
        @ion.field("identity", @ion.IonValue::string(name.to_canonical())),
        @ion.field("allocation_reason", @ion.IonValue::string(reason)),
      ],
    ),
  ).with_annotations(original.annotations())
}