// The `html` subcommand: render a workbook (or one sheet) to a
// self-contained HTML document — the "eyes" half of the agent loop. The
// document goes to stdout by default (wasm-safe) or to `--out`.

///|
async fn run_html(m : @argparse.Matches) -> Unit {
  let file = arg(m, "file")
  let workbook = @lib.open_file(file)
  let max_rows = parse_positive_int(m, "max-rows", 1000)
  let max_cols = parse_positive_int(m, "max-cols", 256)
  let rendered = @xlsx2html.render_workbook(
    workbook,
    title=file,
    sheet=arg(m, "sheet"),
    max_rows~,
    max_cols~,
    include_images=!flag(m, "no-images"),
    calc=flag(m, "calc"),
  )
  // stored strings in a hostile workbook can decode to isolated UTF-16
  // surrogates; both the stdout and --out paths must stay total
  let (document, _) = @xlsx2html.sanitize_utf16_lossy(rendered)
  let out = arg(m, "out")
  if out == "" {
    println(document)
  } else {
    // `html` is a read-only command: writing the document over the input
    // workbook — directly, via `./` spellings, or via a symlink — would
    // destroy it. The write itself goes through the rename-based saver,
    // so even an alias the checks cannot see (a hard link) replaces only
    // the directory entry named by --out; the input's own entry keeps
    // the original bytes.
    if lexical_normalize(out) == lexical_normalize(file) ||
      resolve_existing(out) == resolve_existing(file) {
      raise CliError("error: --out must not be the input workbook")
    }
    // Write through the exclusive-temp + rename saver with the RAW --out
    // path — never a re-resolved one. `rename` replaces the directory
    // entry named by --out itself (it does not follow a symlink at that
    // final component), and CreateNew never truncates, so whatever the
    // --out leaf points to after the guard, the input's own entry keeps
    // its bytes.
    //
    // Residual limitation: an adversary who swaps an INTERMEDIATE
    // directory component of --out (e.g. retargets `slot/` in
    // `slot/out.xlsx`) between the guard and the write could still
    // redirect where the temp+rename lands. Fully closing that needs
    // handle-relative openat/renameat on a pinned directory fd, which
    // moonbitlang/async/fs does not expose (moonbitlang/async#493). It is
    // out of scope here: the
    // threat requires a concurrent process that already holds write
    // access to the output's directory tree — which can clobber the
    // input directly regardless — so it grants no capability a local
    // spreadsheet tool must defend against.
    write_file_replacing(out, @encoding/utf8.encode(document))
    println("wrote \{out}")
  }
}

///|
/// A path's resolved identity: native realpath, falling back to the
/// lexically-normalized spelling whenever realpath fails. That is not only
/// the does-not-exist case -- an existing path under a directory the process
/// cannot search fails with EACCES and takes the same fallback -- and the
/// fallback is what keeps the caller's alias check total.
#cfg(target="native")
async fn resolve_existing(path : String) -> String noraise {
  resolve_save_target(path) catch {
    _ => lexical_normalize(path)
  }
}

///|
/// A path's resolved identity on wasm, where there is no realpath: the path
/// itself. `resolve_save_target` cannot fail here, so there is nothing to
/// fall back from. Callers that need the lexical form ask for it directly.
#cfg(target="wasm")
fn resolve_existing(path : String) -> String noraise {
  resolve_save_target(path)
}

///|
/// Collapses `.` segments and resolves `..` against preceding segments
/// (purely textual — no filesystem access), so `./book.xlsx` and
/// `book.xlsx` compare equal even where realpath is unavailable.
fn lexical_normalize(path : String) -> String {
  let absolute = path.has_prefix("/")
  let out : Array[String] = []
  for segment in path.split("/") {
    let segment = segment.to_owned()
    if segment == "" || segment == "." {
      continue
    }
    if segment == ".." {
      if out.length() > 0 && out[out.length() - 1] != ".." {
        ignore(out.pop())
        continue
      }
      if absolute {
        continue
      }
    }
    out.push(segment)
  }
  let joined = out.join("/")
  if absolute {
    "/" + joined
  } else {
    joined
  }
}

///|
fn parse_positive_int(
  m : @argparse.Matches,
  name : String,
  default : Int32,
) -> Int32 raise CliError {
  let text = arg(m, name)
  if text == "" {
    return default
  }
  let value = @string.parse_int(text) catch {
    _ =>
      raise CliError("error: invalid --\{name} '\{text}' (expected a number)")
  }
  if value < 1 {
    raise CliError("error: --\{name} must be at least 1")
  }
  value
}