///|
fn bounded_decimal_argument(
  matches : @argparse.Matches,
  name : String,
  default_value : Int,
  minimum : Int,
  maximum : Int,
) -> Int raise CliFailure {
  let value = match optional_value(matches, name) {
    Some(text) => text
    None => return default_value
  }
  if value == "" || value.length() > 10 {
    raise docx_cli_failure(
      "office.invalid_arguments",
      "--\{name} must be an integer between \{minimum} and \{maximum}",
      details=Json::object({
        "argument": Json::string(name),
        "minimum": Json::number(minimum.to_double()),
        "maximum": Json::number(maximum.to_double()),
      }),
    )
  }
  let mut result = 0
  for unit in value {
    if !(unit is ('0'..='9')) {
      raise docx_cli_failure(
        "office.invalid_arguments",
        "--\{name} must be an integer between \{minimum} and \{maximum}",
        details=Json::object({
          "argument": Json::string(name),
          "minimum": Json::number(minimum.to_double()),
          "maximum": Json::number(maximum.to_double()),
        }),
      )
    }
    let digit = unit.to_int() - '0'.to_int()
    if result > (maximum - digit) / 10 {
      raise docx_cli_failure(
        "office.invalid_arguments",
        "--\{name} must be an integer between \{minimum} and \{maximum}",
        details=Json::object({
          "argument": Json::string(name),
          "minimum": Json::number(minimum.to_double()),
          "maximum": Json::number(maximum.to_double()),
        }),
      )
    }
    result = result * 10 + digit
  }
  if result < minimum || result > maximum {
    raise docx_cli_failure(
      "office.invalid_arguments",
      "--\{name} must be an integer between \{minimum} and \{maximum}",
      details=Json::object({
        "argument": Json::string(name),
        "minimum": Json::number(minimum.to_double()),
        "maximum": Json::number(maximum.to_double()),
      }),
    )
  }
  result
}

///|
fn cli_max_elements(matches : @argparse.Matches) -> Int raise CliFailure {
  bounded_decimal_argument(
    matches, "max-elements", docx_cli_default_max_elements, 1, docx_cli_hard_max_elements,
  )
}

///|
/// Applies the tighter XLSX scan ceiling before opening or parsing the input
/// package. Content-based format validation still happens after the bounded
/// read, but an `.xlsx` path must never make an oversized request pay that
/// cost first. DOCX retains its independently documented 200,000-node limit.
fn validate_xlsx_max_elements_before_read(
  file : String,
  max_elements : Int,
) -> Unit raise CliFailure {
  if file.to_lower().has_suffix(".xlsx") {
    validate_xlsx_max_elements(max_elements)
  }
}

///|
fn cli_max_output_chars(matches : @argparse.Matches) -> Int raise CliFailure {
  bounded_decimal_argument(
    matches, "max-output-chars", docx_cli_default_max_output_chars, 1, docx_cli_hard_max_output_chars,
  )
}

///|
fn docx_common_options() -> Array[@argparse.OptionArg] {
  [
    OptionArg(
      "max-elements",
      long="max-elements",
      about="maximum DOCX projection nodes or XLSX cells to scan (default 50000; XLSX hard ceiling 100000)",
    ),
    OptionArg(
      "max-output-chars",
      long="max-output-chars",
      about="maximum successful stdout characters, including trailing LF (default 1048576, hard limit 4194304)",
    ),
  ]
}

///|
fn docx_json_flag() -> @argparse.FlagArg {
  FlagArg("json", long="json", about="emit office.output/1 JSON")
}

///|
fn emit_docx_success(output : String) -> Unit {
  println(output)
}

///|
async fn resolve_optional_under(
  projection : DocxProjection,
  matches : @argparse.Matches,
) -> (DocxProjectionEntry?, String?) {
  match optional_value(matches, "under") {
    Some(path) => {
      let entry = resolve_projection_selector(projection, path)
      (Some(entry), Some(entry.path))
    }
    None => (None, None)
  }
}

///|
fn resolve_optional_xlsx_under(
  projection : XlsxProjection,
  matches : @argparse.Matches,
) -> XlsxResolvedSelector? raise CliFailure {
  match optional_value(matches, "under") {
    Some(path) => Some(resolve_xlsx_selector(projection, path))
    None => None
  }
}

///|
async fn run_docx_outline(matches : @argparse.Matches) -> Unit {
  let file = required_value(matches, "file")
  let max_elements = cli_max_elements(matches)
  validate_xlsx_max_elements_before_read(file, max_elements)
  let max_output = cli_max_output_chars(matches)
  let source = read_office_package(file, cancelled=() => {
    @async.is_being_cancelled()
  })
  match source.format {
    Docx => {
      let projection = open_docx_projection_archive(
        file,
        source.archive,
        max_elements,
        cancelled=office_async_cancelled,
      )
      if matches.flags.get_or_default("json", false) {
        emit_docx_success(
          checked_docx_json_output(
            docx_outline_payload(projection, max_output_chars=max_output),
          ),
        )
      } else {
        emit_docx_success(
          checked_docx_human_output(
            docx_outline_human(projection, maximum=max_output),
            max_output,
          ),
        )
      }
    }
    Xlsx => {
      let projection = open_xlsx_projection(source, max_elements, cancelled=() => {
        @async.is_being_cancelled()
      })
      if matches.flags.get_or_default("json", false) {
        emit_docx_success(
          checked_office_json_output(
            xlsx_outline_payload(projection, max_output),
          ),
        )
      } else {
        emit_docx_success(
          checked_office_human_output(
            xlsx_outline_human(projection, max_output),
            max_output,
            "xlsx",
          ),
        )
      }
    }
  }
}

///|
async fn run_docx_get(matches : @argparse.Matches) -> Unit {
  let file = required_value(matches, "file")
  let selector = required_value(matches, "selector")
  let max_elements = cli_max_elements(matches)
  validate_xlsx_max_elements_before_read(file, max_elements)
  let max_output = cli_max_output_chars(matches)
  let source = read_office_package(file, cancelled=() => {
    @async.is_being_cancelled()
  })
  match source.format {
    Docx => {
      let projection = open_docx_projection_archive(
        file,
        source.archive,
        max_elements,
        cancelled=office_async_cancelled,
      )
      let entry = resolve_projection_selector(projection, selector)
      if matches.flags.get_or_default("json", false) {
        emit_docx_success(
          checked_docx_json_output(
            get_docx_payload(projection, entry, max_output),
          ),
        )
      } else {
        let text = match entry.element {
          Some(_) => {
            let available = (max_output - docx_cli_output_framing_chars).max(0)
            let budget = DocxTextBudget::new_reported(
              "successful command output characters", available, max_output, docx_cli_output_framing_chars,
            )
            entry_text_with_budget_cooperative(
              entry,
              budget,
              cancelled=projection.cancelled,
            )
          }
          None => ""
        }
        emit_docx_success(
          checked_docx_human_output(
            docx_get_human(entry, text, projection.warnings, maximum=max_output),
            max_output,
          ),
        )
      }
    }
    Xlsx => {
      let projection = open_xlsx_projection(source, max_elements, cancelled=() => {
        @async.is_being_cancelled()
      })
      let resolved = resolve_xlsx_selector(projection, selector)
      if matches.flags.get_or_default("json", false) {
        emit_docx_success(
          checked_office_json_output(
            xlsx_get_payload(projection, resolved, max_output),
          ),
        )
      } else {
        emit_docx_success(
          checked_office_human_output(
            xlsx_get_human(projection, resolved, max_output),
            max_output,
            "xlsx",
          ),
        )
      }
    }
  }
}

///|
async fn run_docx_text(matches : @argparse.Matches) -> Unit {
  let file = required_value(matches, "file")
  let max_elements = cli_max_elements(matches)
  validate_xlsx_max_elements_before_read(file, max_elements)
  let max_output = cli_max_output_chars(matches)
  let offset = bounded_decimal_argument(
    matches, "offset", 0, 0, docx_cli_hard_max_elements,
  )
  let limit = bounded_decimal_argument(
    matches, "limit", docx_cli_default_text_limit, 0, docx_cli_hard_text_limit,
  )
  let source = read_office_package(file, cancelled=() => {
    @async.is_being_cancelled()
  })
  match source.format {
    Docx => {
      let projection = open_docx_projection_archive(
        file,
        source.archive,
        max_elements,
        cancelled=office_async_cancelled,
      )
      let (under, under_path) = resolve_optional_under(projection, matches)
      if matches.flags.get_or_default("json", false) {
        emit_docx_success(
          checked_docx_json_output(
            docx_text_payload(
              projection, under, under_path, offset, limit, max_output,
            ),
          ),
        )
      } else {
        emit_docx_success(
          checked_docx_human_output(
            docx_text_human(
              projection,
              under,
              offset,
              limit,
              projection.warnings,
              maximum=max_output,
            ),
            max_output,
          ),
        )
      }
    }
    Xlsx => {
      let projection = open_xlsx_projection(source, max_elements, cancelled=() => {
        @async.is_being_cancelled()
      })
      let under = resolve_optional_xlsx_under(projection, matches)
      if matches.flags.get_or_default("json", false) {
        emit_docx_success(
          checked_office_json_output(
            xlsx_text_payload(projection, under, offset, limit, max_output),
          ),
        )
      } else {
        emit_docx_success(
          checked_office_human_output(
            xlsx_text_human(projection, under, offset, limit, max_output),
            max_output,
            "xlsx",
          ),
        )
      }
    }
  }
}

///|
fn canonical_query_kind(input : String) -> String? {
  match input.to_lower() {
    "body" => Some("body")
    "header" => Some("header")
    "footer" => Some("footer")
    "footnotes" => Some("footnotes")
    "endnotes" => Some("endnotes")
    "comments" => Some("comments")
    "note" => Some("note")
    "comment" => Some("comment")
    "p" | "paragraph" => Some("p")
    "r" | "run" => Some("r")
    "tbl" | "table" => Some("tbl")
    "tr" | "row" => Some("tr")
    "tc" | "cell" => Some("tc")
    "hyperlink" | "link" => Some("hyperlink")
    "image" | "picture" => Some("image")
    _ => None
  }
}

///|
fn query_kind(matches : @argparse.Matches) -> String? raise CliFailure {
  match optional_value(matches, "kind") {
    Some(value) =>
      match canonical_query_kind(value) {
        Some(kind) => Some(kind)
        None =>
          raise docx_cli_failure(
            "office.docx.invalid_query_kind",
            "unknown DOCX query kind '\{bounded_text(value, 80)}'",
            details=Json::object({
              "kind": Json::string(bounded_text(value, 80)),
              "allowed": Json::array([
                Json::string("body"),
                Json::string("header"),
                Json::string("footer"),
                Json::string("footnotes"),
                Json::string("endnotes"),
                Json::string("comments"),
                Json::string("note"),
                Json::string("comment"),
                Json::string("p"),
                Json::string("r"),
                Json::string("tbl"),
                Json::string("tr"),
                Json::string("tc"),
                Json::string("hyperlink"),
                Json::string("image"),
              ]),
            }),
          )
      }
    None => None
  }
}

///|
fn canonical_query_property_name(input : String) -> String? {
  match input.to_lower() {
    "style_id" | "style-id" => Some("style_id")
    "style_name" | "style-name" => Some("style_name")
    "alignment" | "align" => Some("alignment")
    "bold" => Some("bold")
    "italic" => Some("italic")
    "underline" => Some("underline")
    "content_type" | "content-type" => Some("content_type")
    "href" | "url" => Some("href")
    "author" => Some("author")
    "done" | "resolved" => Some("done")
    _ => None
  }
}

///|
fn validate_query_value(
  value : String,
  argument : String,
  allow_empty : Bool,
) -> Unit raise CliFailure {
  let maximum = docx_cli_max_xml_token_chars
  if (!allow_empty && value == "") || !has_at_most_chars(value, maximum) {
    let requirement = if allow_empty { "a value" } else { "a non-empty value" }
    raise docx_cli_failure(
      "office.invalid_arguments",
      "--\{argument} must be \{requirement} of at most \{maximum} characters",
      details=Json::object({
        "argument": Json::string(argument),
        "maximum_characters": Json::number(maximum.to_double()),
      }),
    )
  }
}

///|
fn query_properties(
  matches : @argparse.Matches,
) -> Array[DocxPropertyPredicate] raise CliFailure {
  let raw = repeated_values(matches, "property")
  if raw.length() > 16 {
    raise docx_cli_failure(
      "office.docx.query_predicate_limit",
      "at most 16 --property predicates are allowed",
      details=Json::object({
        "limit": Json::number(16),
        "actual": Json::number(raw.length().to_double()),
      }),
    )
  }
  let predicates : Array[DocxPropertyPredicate] = []
  for value in raw {
    guard value.find("=") is Some(split) && split > 0 else {
      raise docx_cli_failure(
        "office.invalid_arguments",
        "--property requires NAME=VALUE",
        details=Json::object({
          "argument": Json::string("property"),
          "value": Json::string(bounded_text(value, 160)),
        }),
      )
    }
    let raw_name = value[:split].to_owned()
    let property_value = value[split + 1:].to_owned()
    guard canonical_query_property_name(raw_name) is Some(name) else {
      raise docx_cli_failure(
        "office.docx.invalid_query_property",
        "unknown DOCX query property '\{bounded_text(raw_name, 80)}'",
        details=Json::object({
          "property": Json::string(bounded_text(raw_name, 80)),
        }),
      )
    }
    validate_query_value(property_value, "property", true)
    if name == "bold" ||
      name == "italic" ||
      name == "underline" ||
      name == "done" {
      if property_value != "true" && property_value != "false" {
        raise docx_cli_failure(
          "office.docx.invalid_query_property",
          "boolean query property '\{name}' requires true or false",
          details=Json::object({
            "property": Json::string(name),
            "value": Json::string(bounded_text(property_value, 80)),
          }),
        )
      }
    }
    predicates.push({ name, value: property_value, })
  }
  predicates
}

///|
fn reject_xlsx_query_options(
  matches : @argparse.Matches,
) -> Unit raise CliFailure {
  let incompatible : Array[String] = []
  for name in ["kind", "text", "id"] {
    if optional_value(matches, name) is Some(_) {
      incompatible.push("--" + name)
    }
  }
  if !repeated_values(matches, "property").is_empty() {
    incompatible.push("--property")
  }
  if matches.flags.get_or_default("ignore-case", false) {
    incompatible.push("--ignore-case")
  }
  if !incompatible.is_empty() {
    raise xlsx_cli_failure(
      "office.xlsx.unsupported_query_options",
      "DOCX query options cannot be used with an XLSX workbook; use the cell selector predicates",
      details=Json::object({
        "options": Json::array(incompatible.map(Json::string)),
      }),
    )
  }
}

///|
fn reject_docx_content_selector(
  matches : @argparse.Matches,
) -> Unit raise CliFailure {
  match optional_value(matches, "selector") {
    Some(value) =>
      raise docx_cli_failure(
        "office.docx.unsupported_query_selector",
        "the positional cell selector is available only for XLSX queries",
        details=Json::object({
          "selector": Json::string(bounded_text(value, 240)),
        }),
      )
    None => ()
  }
}

///|
async fn run_docx_query(matches : @argparse.Matches) -> Unit {
  let file = required_value(matches, "file")
  let max_elements = cli_max_elements(matches)
  validate_xlsx_max_elements_before_read(file, max_elements)
  let max_output = cli_max_output_chars(matches)
  let offset = bounded_decimal_argument(
    matches, "offset", 0, 0, docx_cli_hard_max_elements,
  )
  let limit = bounded_decimal_argument(
    matches, "limit", docx_cli_default_query_limit, 0, docx_cli_hard_query_limit,
  )
  let source = read_office_package(file, cancelled=() => {
    @async.is_being_cancelled()
  })
  match source.format {
    Docx => {
      reject_docx_content_selector(matches)
      let text = optional_value(matches, "text")
      match text {
        Some(value) => validate_query_value(value, "text", false)
        None => ()
      }
      let stable_id = optional_value(matches, "id")
      match stable_id {
        Some(value) => validate_query_value(value, "id", false)
        None => ()
      }
      let projection = open_docx_projection_archive(
        file,
        source.archive,
        max_elements,
        cancelled=office_async_cancelled,
      )
      let (under, under_path) = resolve_optional_under(projection, matches)
      let spec : DocxQuerySpec = {
        under: under_path,
        kind: query_kind(matches),
        text,
        stable_id,
        properties: query_properties(matches),
        ignore_case: matches.flags.get_or_default("ignore-case", false),
        offset,
        limit,
      }
      if matches.flags.get_or_default("json", false) {
        emit_docx_success(
          checked_docx_json_output(
            docx_query_payload(projection, under, spec, max_output),
          ),
        )
      } else {
        emit_docx_success(
          checked_docx_human_output(
            docx_query_human(
              projection,
              under,
              spec,
              projection.warnings,
              maximum=max_output,
            ),
            max_output,
          ),
        )
      }
    }
    Xlsx => {
      reject_xlsx_query_options(matches)
      let selector = optional_value(matches, "selector").unwrap_or("cell")
      let projection = open_xlsx_projection(source, max_elements, cancelled=() => {
        @async.is_being_cancelled()
      })
      let under = resolve_optional_xlsx_under(projection, matches)
      let spec : XlsxQuerySpec = {
        selector,
        predicates: parse_xlsx_query_selector(selector),
        offset,
        limit,
      }
      if matches.flags.get_or_default("json", false) {
        emit_docx_success(
          checked_office_json_output(
            xlsx_query_payload(projection, under, spec, max_output),
          ),
        )
      } else {
        emit_docx_success(
          checked_office_human_output(
            xlsx_query_human(projection, under, spec, max_output),
            max_output,
            "xlsx",
          ),
        )
      }
    }
  }
}

///|
fn outline_command() -> @argparse.Command {
  Command(
    "outline",
    about="Summarize bounded XLSX or DOCX structure using canonical selectors",
    positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())],
    options=docx_common_options(),
    flags=[docx_json_flag()],
  )
}

///|
fn get_command() -> @argparse.Command {
  Command(
    "get",
    about="Resolve one canonical XLSX or DOCX selector",
    positionals=[
      PositionArg("file", num_args=@argparse.ValueRange::single()),
      PositionArg(
        "selector",
        about="canonical path such as /docx/body/p[1] or /xlsx/sheet[name=\"Data\"]/cell[A1]",
        num_args=@argparse.ValueRange::single(),
      ),
    ],
    options=docx_common_options(),
    flags=[docx_json_flag()],
  )
}

///|
fn text_command() -> @argparse.Command {
  let options = docx_common_options()
  options.append([
    OptionArg(
      "under",
      long="under",
      about="restrict results to a canonical DOCX subtree or XLSX sheet/range/cell",
    ),
    OptionArg("offset", long="offset", about="zero-based result offset"),
    OptionArg(
      "limit",
      long="limit",
      about="maximum returned paragraphs or cells (default 2000, hard limit 10000)",
    ),
  ])
  Command(
    "text",
    about="Extract bounded path-tagged XLSX cell or DOCX paragraph text",
    positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())],
    options~,
    flags=[docx_json_flag()],
  )
}

///|
fn query_command() -> @argparse.Command {
  let options = docx_common_options()
  options.append([
    OptionArg(
      "under",
      long="under",
      about="restrict matches to a canonical DOCX subtree or XLSX sheet/range/cell",
    ),
    OptionArg(
      "kind",
      long="kind",
      about="DOCX only: exact element kind (aliases: paragraph, run, table, row, cell, link, picture)",
    ),
    OptionArg(
      "text",
      long="text",
      about="DOCX only: literal text substring (no regular expressions)",
    ),
    OptionArg("id", long="id", about="DOCX only: exact annotation id"),
    OptionArg(
      "property",
      long="property",
      action=Append,
      about="DOCX only: repeatable declared property predicate NAME=VALUE",
    ),
    OptionArg("offset", long="offset", about="zero-based match offset"),
    OptionArg(
      "limit",
      long="limit",
      about="maximum returned matches (default 100, hard limit 1000)",
    ),
  ])
  Command(
    "query",
    about="Run bounded deterministic predicates over XLSX cells or DOCX elements",
    positionals=[
      PositionArg("file", num_args=@argparse.ValueRange::single()),
      PositionArg(
        "selector",
        about="XLSX only: cell[predicate] selector (default cell)",
        num_args=ValueRange(lower=0, upper=1),
      ),
    ],
    options~,
    flags=[
      docx_json_flag(),
      FlagArg(
        "ignore-case",
        long="ignore-case",
        about="compare --text with locale-independent Unicode simple case mapping",
      ),
    ],
  )
}