///|
/// Entry point: `yxl build [] [-o ]`. Filesystem access and the
/// process exit code live only here (ADR-003); the compile pipeline behind
/// `@cli` is I/O-free.
fn main {
  @sys.exit(run())
}

///|
/// Parse arguments, run the requested command, and return a process exit code.
fn run() -> Int32 {
  // `args` includes the program name at index 0.
  let args = @env.args()[1:]
  let command = @cli.parse_args(args) catch {
    @diag.CliError(d) => {
      report(d.render())
      return @cli.exit_usage
    }
  }
  match command {
    Build(input~, output~, params~) =>
      match spec_or_found(input) {
        Ok(input) => build(input, output, params)
        Err(code) => code
      }
    Check(input~, params~) =>
      match spec_or_found(input) {
        Ok(input) => check(input, params)
        Err(code) => code
      }
    Extract(input~, output~, flat~) => extract(input, output, flat)
    Init(output~, force~) => init_spec(output, force)
    Version => {
      println("yxl \{@cli.version}")
      @cli.exit_ok
    }
    Help => {
      println(@cli.usage)
      @cli.exit_ok
    }
  }
}

///|
/// The spec the command line named, or else the one in the current directory.
/// `Err` carries the exit code, once the reason there is none has been reported.
fn spec_or_found(input : String?) -> Result[String, Int32] {
  if input is Some(input) {
    return Ok(input)
  }
  let names = @fs.read_dir(".") catch {
    @fs.IOError(reason) => {
      report("cannot list the current directory: \{reason}")
      return Err(@cli.exit_error)
    }
  }
  Ok(@cli.find_spec(names)) catch {
    @diag.CliError(d) => {
      report(d.render())
      Err(@cli.exit_usage)
    }
  }
}

///|
/// Compile `input` without writing anything: the whole pipeline runs, so a spec
/// that passes here is one `build` would accept.
fn check(input : String, params : Map[String, String]) -> Int32 {
  guard read_source(input) is Some(source) else { return @cli.exit_error }
  guard compile(input, source, params) is Some(_) else {
    return @cli.exit_error
  }
  report("\{input}: ok")
  @cli.exit_ok
}

///|
/// Read `input`, compile it, and write the `.xlsx` bytes to `output` — or,
/// without one, to wherever the spec says (`@cli.Compiled`).
fn build(
  input : String,
  output : String?,
  params : Map[String, String],
) -> Int32 {
  guard read_source(input) is Some(source) else { return @cli.exit_error }
  let compiled = @cli.build(
    source,
    file=input,
    resolve=read_included,
    read_bytes=read_included_bytes,
    params~,
  ) catch {
    err => {
      report(@cli.error_message(err, source~))
      return @cli.exit_error
    }
  }
  let output = output.unwrap_or(compiled.output)
  guard write_file(output, compiled.bytes) else { return @cli.exit_error }
  report("wrote \{output}")
  @cli.exit_ok
}

///|
/// Run the whole pipeline over a spec already read, wiring in the two disk-side
/// resolvers. A failure is reported against the source — so the offending line
/// is quoted — and returns `None`.
fn compile(
  input : String,
  source : String,
  params : Map[String, String],
) -> Bytes? {
  Some(
    @cli.compile(
      source,
      file=input,
      resolve=read_included,
      read_bytes=read_included_bytes,
      params~,
    ),
  ) catch {
    err => {
      report(@cli.error_message(err, source~))
      None
    }
  }
}

///|
/// Write the starter spec to `output` (#77), refusing an existing file unless
/// `--force` says otherwise. Not named `init`: that is MoonBit's package
/// initializer, which takes no arguments.
fn init_spec(output : String, force : Bool) -> Int32 {
  if !force && @fs.path_exists(output) {
    report("\{output} already exists (use --force to overwrite)")
    return @cli.exit_error
  }
  make_parent(output)
  @fs.write_string_to_file(output, @cli.starter_spec(output~)) catch {
    @fs.IOError(reason) => {
      report("cannot write \{output}: \{reason}")
      return @cli.exit_error
    }
  }
  report("wrote \{output}")
  @cli.exit_ok
}

///|
/// Read a workbook and write the spec that would rebuild it (ADR-017). A spec
/// that failed its self-check is still written, with a failing exit code.
fn extract(input : String, output : String, flat : Bool) -> Int32 {
  let bytes = @fs.read_file_to_bytes(input) catch {
    @fs.IOError(reason) => {
      report("cannot read \{input}: \{reason}")
      return @cli.exit_error
    }
  }
  let extracted = @cli.extract(bytes, file=input, flat~) catch {
    err => {
      report(@cli.error_message(err))
      return @cli.exit_error
    }
  }
  make_parent(output)
  @fs.write_string_to_file(output, extracted.spec) catch {
    @fs.IOError(reason) => {
      report("cannot write \{output}: \{reason}")
      return @cli.exit_error
    }
  }
  report("wrote \{output}")
  // The spec names these by a path relative to itself, which is where they have
  // to land for `yxl build` to find them again.
  for companion in extracted.companions {
    let path = @cli.resolve_path(output, companion.path)
    guard write_file(path, companion.content) else { return @cli.exit_error }
    report("     + \{path}")
  }
  for note in extracted.dropped {
    report("  dropped: \{note}")
  }
  guard extracted.warnings.is_empty() else {
    report("the extracted spec did not rebuild the workbook exactly:")
    for warning in extracted.warnings {
      report("  \{warning}")
    }
    return @cli.exit_error
  }
  if !extracted.dropped.is_empty() {
    report("everything else rebuilds the workbook as read")
  }
  @cli.exit_ok
}

///|
/// Write `content` to `path`, making the directory it sits in first: an
/// extracted spec puts its tables under `data/`, and a spec's `output:` may
/// name a `dist/` nobody has built into before.
fn write_file(path : String, content : Bytes) -> Bool {
  make_parent(path)
  @fs.write_bytes_to_file(path, content) catch {
    @fs.IOError(reason) => {
      report("cannot write \{path}: \{reason}")
      return false
    }
  }
  true
}

///|
/// Make the directory `path` sits in, so `-o out/book.yxl.yaml` needs no
/// `mkdir` first.
fn make_parent(path : String) -> Unit {
  if @cli.directory_of(path) is Some(directory) {
    make_directories(directory)
  }
}

///|
/// Make `directory` and any parent it lacks, outermost first.
fn make_directories(directory : String) -> Unit {
  let trimmed = directory[:directory.length() - 1].to_owned()
  if trimmed.is_empty() || @fs.path_exists(trimmed) {
    return
  }
  if @cli.directory_of(trimmed) is Some(parent) {
    make_directories(parent)
  }
  @fs.create_dir(trimmed) catch {
    // One that genuinely cannot be made shows up as the write failing next.
    _ => ()
  }
}

///|
/// Read the spec itself, reporting a failure and returning `None` — the shared
/// front door of `build` and `check`.
fn read_source(input : String) -> String? {
  Some(@fs.read_file_to_string(input)) catch {
    @fs.IOError(reason) => {
      report("cannot read \{input}: \{reason}")
      None
    }
  }
}

///|
/// Read the file an `$include` names, relative to the file containing it, so a
/// spec can be moved as a directory. This is the disk half of includes; the
/// loader itself never opens a file (ADR-003).
fn read_included(from : String, path : String) -> @include.Included raise {
  let name = @cli.resolve_path(from, path)
  let source = @fs.read_file_to_string(name) catch {
    @fs.IOError(reason) => raise read_error(from, name, reason)
  }
  { name, source, }
}

///|
/// Read a file the spec names but cannot hold: an image's bytes. Resolved the
/// same way as an `$include`, and, like it, the disk half of a seam the loader
/// itself never crosses (ADR-003).
fn read_included_bytes(
  from : String,
  path : String,
) -> @include.IncludedBytes raise {
  let name = @cli.resolve_path(from, path)
  let data = @fs.read_file_to_bytes(name) catch {
    @fs.IOError(reason) => raise read_error(from, name, reason)
  }
  { name, data, }
}

///|
/// The one wording for "a file the spec names could not be read", whether it
/// held YAML or bytes.
fn read_error(
  from : String,
  name : String,
  reason : String,
) -> @diag.SchemaError {
  @diag.SchemaError(
    @diag.Diagnostic("\{from}: cannot read '\{name}': \{reason}"),
  )
}

///|
/// Print one `yxl: …` line — to stdout, diagnostics included, since the
/// toolchain has no `eprintln`.
fn report(message : String) -> Unit {
  println("yxl: \{message}")
}