///|
suberror SubclassCommandError {
  Failure(String)
} derive(Debug)

///|
/// Raw CLI values. Optional overrides stay separate from the resolved command
/// so the common one-input invocation can discover every development detail.
struct SubclassCommandOptions {
  inputs : Array[String]
  config_path : String?
  gir_dirs : Array[String]
  output_dir : String?
  symbol_prefix : String?
}

///|
struct SubclassCommand {
  source : @subclass_model.SubclassSource
  config_path : String
  gir_search_path : GirSearchPath
  output_dir : @path.Path
  parent_import_alias : String
  symbol_prefix : String
}

///|
async fn SubclassCommandOptions::read_text(
  self : SubclassCommandOptions,
  path : String,
) -> String raise SubclassCommandError {
  ignore(self)
  let data = @fs.read_file(path) catch {
    error =>
      raise SubclassCommandError::Failure("failed to read \{path}: \{error}")
  }
  data.text() catch {
    error =>
      raise SubclassCommandError::Failure(
        "failed to decode \{path} as text: \{error}",
      )
  }
}

///|
/// Read all subclass declarations before resolving defaults. Qualified parent
/// aliases and the declared GType provide the stable inferred values.
fn SubclassCommandOptions::load_source(
  self : SubclassCommandOptions,
) -> @subclass_model.SubclassSource raise SubclassCommandError {
  let source = @subclass_model.SubclassSource::new()
  for input in self.inputs {
    let parser = @subclass_parser.SubclassParser(input)
    let parsed = parser.parse_file(input) catch {
      error => raise SubclassCommandError::Failure(error.message())
    }
    source.append(parsed)
  }
  source
}

///|
/// The command currently generates one parent binding family at a time. The
/// custom subclasses themselves remain types in the consuming MoonBit package;
/// this value only selects the external binding import used for their parents.
fn SubclassCommandOptions::resolve_parent_import_alias(
  self : SubclassCommandOptions,
  source : @subclass_model.SubclassSource,
) -> String raise SubclassCommandError {
  ignore(self)
  let mut import_alias : String? = None
  for declaration in source.declarations {
    guard declaration.parent.split_once(".") is Some((qualifier, parent_name)) &&
      qualifier.has_prefix("@") &&
      qualifier.length() > 1 &&
      parent_name != "" &&
      !parent_name.contains(".") else {
      raise SubclassCommandError::Failure(
        "\{declaration.name}: parent '\{declaration.parent}' must be qualified as @pkg.Type",
      )
    }
    let next = qualifier[1:].to_owned()
    if import_alias is Some(current) && current != next {
      raise SubclassCommandError::Failure(
        "one invocation cannot load parent bindings from both @\{current} and @\{next}",
      )
    }
    import_alias = Some(next)
  }
  if import_alias is Some(resolved_alias) {
    return resolved_alias
  }
  raise SubclassCommandError::Failure(
    "cannot determine a parent binding package because the input has no #gobject.subclass declaration",
  )
}

///|
fn SubclassCommandOptions::infer_symbol_prefix(
  self : SubclassCommandOptions,
  source : @subclass_model.SubclassSource,
) -> String raise SubclassCommandError {
  ignore(self)
  guard source.declarations.get(0) is Some(declaration) else {
    raise SubclassCommandError::Failure(
      "cannot infer a C symbol prefix because the input has no #gobject.subclass declaration",
    )
  }
  let gtype_name = declaration.gtype_name.unwrap_or(
    "MoonBit\{declaration.name}",
  )
  @case_conv.to_snake(gtype_name)
}

///|
fn SubclassCommandOptions::infer_output_dir(
  self : SubclassCommandOptions,
) -> @path.Path raise SubclassCommandError {
  guard self.inputs.get(0) is Some(first) else {
    raise SubclassCommandError::Failure("at least one input file is required")
  }
  let output_dir = @path.Path(first).resolve().dirname()
  for input in self.inputs[1:] {
    let next = @path.Path(input).resolve().dirname()
    if next != output_dir {
      raise SubclassCommandError::Failure(
        "input files are in different directories; pass --output",
      )
    }
  }
  output_dir
}

///|
fn SubclassCommandOptions::search_roots(
  self : SubclassCommandOptions,
) -> Array[@path.Path] {
  let roots : Array[@path.Path] = []
  for input in self.inputs {
    let root = @path.Path(input).resolve().dirname()
    if !roots.contains(root) {
      roots.push(root)
    }
  }
  if @env.current_dir() is Some(current_dir) {
    let root = @path.Path(current_dir).resolve()
    if !roots.contains(root) {
      roots.push(root)
    }
  }
  roots
}

///|
async fn SubclassCommandOptions::path_exists(
  self : SubclassCommandOptions,
  path : @path.Path,
) -> Bool raise SubclassCommandError {
  ignore(self)
  @fs.exists(path.0) catch {
    error =>
      raise SubclassCommandError::Failure(
        "failed to inspect \{path.0}: \{error}",
      )
  }
}

///|
/// Search both a bindings monorepo and dependency sources materialized under
/// `.mooncakes`, walking upward from the inputs and current directory.
async fn SubclassCommandOptions::find_config_path(
  self : SubclassCommandOptions,
  parent_binding : PackageImport,
) -> String raise SubclassCommandError {
  for root in self.search_roots() {
    let mut directory = root
    for ;; {
      let local_config = parent_binding.local_config_path(directory)
      if self.path_exists(local_config) {
        return local_config.0
      }
      let mooncakes = directory.join(@path.Path(".mooncakes"))
      if self.path_exists(mooncakes) {
        let dependency = parent_binding.materialized_config_path(mooncakes)
        if self.path_exists(dependency) {
          return dependency.0
        }
      }
      let parent = directory.dirname()
      if parent == directory {
        break
      }
      directory = parent
    }
  }
  raise SubclassCommandError::Failure(
    "cannot find gir.json for @\{parent_binding.import_alias} imported from '\{parent_binding.import_path}'; pass --config",
  )
}

///|
async fn SubclassCommandOptions::load_config(
  self : SubclassCommandOptions,
  config_path : String,
) -> @config.GirConfig raise SubclassCommandError {
  let config_text = self.read_text(config_path)
  @json.from_json(@json.parse(config_text)) catch {
    error =>
      raise SubclassCommandError::Failure(
        "failed to parse \{config_path}: \{error}",
      )
  }
}

///|
/// Build the platform search order without assuming one package manager.
/// One or more explicit `--gir-dir` values bypass automatic discovery.
fn SubclassCommandOptions::gir_search_path(
  self : SubclassCommandOptions,
) -> GirSearchPath {
  if !self.gir_dirs.is_empty() {
    return GirSearchPath(self.gir_dirs[:])
  }
  let directories : Array[String] = []
  if @env.get_env_var("GI_GIR_PATH") is Some(paths) {
    for path in paths.split("\{@path.delimiter}") {
      directories.push(path.to_owned())
    }
  }
  if @env.get_env_var("XDG_DATA_DIRS") is Some(paths) {
    for path in paths.split("\{@path.delimiter}") {
      directories.push(
        @path.Path(path.to_owned()).join(@path.Path("gir-1.0")).0,
      )
    }
  }
  if @env.get_env_var("HOMEBREW_PREFIX") is Some(prefix) {
    directories.push(@path.Path(prefix).join(@path.Path("share/gir-1.0")).0)
  }
  directories.push("/opt/homebrew/share/gir-1.0")
  directories.push("/opt/local/share/gir-1.0")
  directories.push("/usr/local/share/gir-1.0")
  directories.push("/usr/share/gir-1.0")
  GirSearchPath(directories[:])
}

///|
/// Resolve all optional CLI fields before any generated file is written.
async fn SubclassCommandOptions::resolve(
  self : SubclassCommandOptions,
) -> SubclassCommand raise SubclassCommandError {
  let source = self.load_source()
  let parent_import_alias = self.resolve_parent_import_alias(source)
  let config_path = if self.config_path is Some(path) {
    path
  } else {
    let manifest = self.load_package_manifest()
    let parent_binding = manifest.resolve_import(parent_import_alias)
    self.find_config_path(parent_binding)
  }
  let config = self.load_config(config_path)
  let gir_search_path = self.gir_search_path()
  gir_search_path.validate(config)
  let output_dir = if self.output_dir is Some(path) {
    @path.Path(path)
  } else {
    self.infer_output_dir()
  }
  let symbol_prefix = if self.symbol_prefix is Some(prefix) {
    prefix
  } else {
    self.infer_symbol_prefix(source)
  }
  {
    source,
    config_path,
    gir_search_path,
    output_dir,
    parent_import_alias,
    symbol_prefix,
  }
}

///|
async fn SubclassCommand::read_text(
  self : SubclassCommand,
  path : String,
) -> String raise SubclassCommandError {
  ignore(self)
  let data = @fs.read_file(path) catch {
    error =>
      raise SubclassCommandError::Failure("failed to read \{path}: \{error}")
  }
  data.text() catch {
    error =>
      raise SubclassCommandError::Failure(
        "failed to decode \{path} as text: \{error}",
      )
  }
}

///|
/// Formatting is optional, but failure to report a formatting problem is not.
async fn SubclassCommand::write_warning(
  self : SubclassCommand,
  message : String,
) -> Unit raise SubclassCommandError {
  ignore(self)
  @stdio.stderr.write("gir-subclass: warning: \{message}\n") catch {
    error =>
      raise SubclassCommandError::Failure(
        "failed to report formatter warning: \{error}",
      )
  }
}

///|
async fn SubclassCommand::read_gir(
  self : SubclassCommand,
  gir_name : String,
) -> @gir_parser.GirNamespace raise SubclassCommandError {
  let gir_path = self.gir_search_path.resolve_gir(gir_name)
  let source = self.read_text(gir_path.0)
  @gir_parser.parse_gir(source) catch {
    error =>
      raise SubclassCommandError::Failure(
        "failed to parse \{gir_path.0}: \{error.to_string()}",
      )
  }
}

///|
async fn SubclassCommand::load_repository(
  self : SubclassCommand,
) -> @subclass_generator.SubclassRepository raise SubclassCommandError {
  let config_text = self.read_text(self.config_path)
  let config : @config.GirConfig = @json.from_json(@json.parse(config_text)) catch {
    error =>
      raise SubclassCommandError::Failure(
        "failed to parse \{self.config_path}: \{error}",
      )
  }
  let parent_namespace = self.read_gir(config.gir)
  let repository = @subclass_generator.SubclassRepository::new(
    parent_namespace,
    "@\{self.parent_import_alias}",
    config.classes,
    config.header,
    selected_aliases=config.aliases,
    selected_interfaces=config.interfaces,
    selected_enums=config.enums,
    selected_records=config.records,
    record_lifecycles=config.record_lifecycles,
  ) catch {
    error => raise SubclassCommandError::Failure(error.message())
  }
  for gir_name, dependency in config.deps {
    let gir_namespace = self.read_gir(gir_name)
    repository.register_namespace(
      gir_namespace,
      dependency.import_path,
      dependency.classes,
      selected_aliases=dependency.aliases,
      selected_interfaces=dependency.interfaces,
      selected_enums=dependency.enums,
      selected_records=dependency.records,
    ) catch {
      error => raise SubclassCommandError::Failure(error.message())
    }
  }
  repository
}

///|
async fn SubclassCommand::write_files(
  self : SubclassCommand,
  files : Array[@subclass_generator.GeneratedFile],
) -> Unit raise SubclassCommandError {
  let output_exists = @fs.exists(self.output_dir.0) catch {
    error =>
      raise SubclassCommandError::Failure(
        "failed to inspect \{self.output_dir.0}: \{error}",
      )
  }
  if !output_exists {
    @fs.mkdir(self.output_dir.0, permission=0o755, recursive=true) catch {
      error =>
        raise SubclassCommandError::Failure(
          "failed to create \{self.output_dir.0}: \{error}",
        )
    }
  }
  let c_files : Array[String] = []
  let moonbit_files : Array[String] = []
  for file in files {
    let path = self.output_dir.join(@path.Path(file.filename))
    @fs.write_file(
      path.0,
      file.content,
      create_mode=CreateOrTruncate,
      permission=0o644,
    ) catch {
      error =>
        raise SubclassCommandError::Failure(
          "failed to write \{path.0}: \{error}",
        )
    }
    @stdio.stdout.write("  \{path.0}\n") catch {
      error =>
        raise SubclassCommandError::Failure(
          "failed to report generated file \{path.0}: \{error}",
        )
    }
    let extension = path.extname()
    if extension == ".c" || extension == ".h" {
      c_files.push(path.0)
    } else if extension == ".mbt" {
      moonbit_files.push(path.0)
    }
  }
  // Generated files are committed and may also be `dev_build` outputs. Format
  // them when the tools are available, but do not turn clang-format into a
  // runtime requirement for every consuming module.
  if !c_files.is_empty() {
    let arguments : Array[String] = ["-i"]
    arguments.append(c_files[:])
    let exit_code = @process.run("clang-format", arguments[:]) catch {
      error => {
        self.write_warning("failed to run clang-format: \{error}")
        -1
      }
    }
    if exit_code != 0 && exit_code != -1 {
      self.write_warning("clang-format exited with status \{exit_code}")
    }
  }
  for path in moonbit_files {
    let (exit_code, output) = @process.collect_stdout("moonfmt", [path][:]) catch {
      error => {
        self.write_warning("failed to run moonfmt for \{path}: \{error}")
        (-1, "")
      }
    }
    if exit_code == 0 {
      @fs.write_file(
        path,
        output,
        create_mode=CreateOrTruncate,
        permission=0o644,
      ) catch {
        error =>
          raise SubclassCommandError::Failure(
            "failed to write formatted \{path}: \{error}",
          )
      }
    } else if exit_code != -1 {
      self.write_warning("moonfmt exited with status \{exit_code} for \{path}")
    }
  }
}

///|
async fn SubclassCommand::run(
  self : SubclassCommand,
) -> Unit raise SubclassCommandError {
  let repository = self.load_repository()
  let generator = @subclass_generator.SubclassGenerator::new(
    repository,
    self.symbol_prefix,
  ) catch {
    error => raise SubclassCommandError::Failure(error.message())
  }
  let files = generator.generate(self.source) catch {
    error => raise SubclassCommandError::Failure(error.message())
  }
  self.write_files(files)
}

///|
fn SubclassCommandOptions::parse(
  argv? : ArrayView[String],
) -> SubclassCommandOptions raise {
  let command = @argparse.Command(
    "gir-subclass",
    about="Generate MoonBit and C definitions for GIR-backed GObject subclasses",
    positionals=[
      @argparse.PositionArg(
        "inputs",
        about="MoonBit source files containing subclass declarations and V implementations",
        num_args=@argparse.ValueRange(lower=1),
      ),
    ],
    options=[
      @argparse.OptionArg(
        "config",
        long="config",
        about="Override the inferred gir.json for the parent binding package",
      ),
      @argparse.OptionArg(
        "symbol-prefix",
        long="symbol-prefix",
        about="Override the C identifier prefix inferred from the subclass GType",
      ),
      @argparse.OptionArg(
        "output",
        short='o',
        long="output",
        about="Override the input directory used for generated .mbt and .c files",
      ),
      @argparse.OptionArg(
        "gir-dir",
        long="gir-dir",
        action=@argparse.OptionAction::Append,
        about="Add a GIR search directory; repeat to search multiple directories in order",
      ),
    ],
  )
  let matches = command.parse(argv?)
  {
    inputs: matches.values.get("inputs").unwrap_or([]),
    config_path: matches.values.get("config").map(values => values[0]),
    gir_dirs: matches.values.get("gir-dir").unwrap_or([]),
    output_dir: matches.values.get("output").map(values => values[0]),
    symbol_prefix: matches.values.get("symbol-prefix").map(values => values[0]),
  }
}

///|
async fn main {
  let options = SubclassCommandOptions::parse() catch {
    error => {
      @stdio.stderr.write("\{error}\n")
      @sys.exit(2)
      return
    }
  }
  options.resolve().run() catch {
    SubclassCommandError::Failure(message) => {
      @stdio.stderr.write("gir-subclass: \{message}\n")
      // `rule` / `dev_build` must observe generation failures, not just text.
      @sys.exit(1)
    }
  }
}