///|
async fn main {
  let command = @argparse.Command(
    "obsmark",
    about="Render markdown documents anywhere. ",
    flags=[
      @argparse.FlagArg(
        "ascii",
        about="Render text tables with ASCII line style.",
        short='a',
      ),
      @argparse.FlagArg(
        "ansi",
        about="Render plain text with ANSI escape codes.",
      ),
      @argparse.FlagArg(
        "watch",
        about="Watch the input file and re-render on changes.",
        short='w',
      ),
      @argparse.FlagArg(
        "disable-math",
        about="Do not render math; keep $...$ and $$...$$ as-is.",
      ),
      @argparse.FlagArg(
        "cmark",
        about="Render with the cmark (CommonMark) parser instead of notiz.",
        short='c',
      ),
      @argparse.FlagArg(
        "halfwidth",
        about="Replace full-width punctuation with half-width in text (not in code or math).",
      ),
    ],
    options=[
      OptionArg("output", about="Output file path.", short='o'),
      OptionArg(
        "target",
        about="Output format (text|html|html-text).",
        short='t',
        default_values=["text"],
      ),
      OptionArg(
        "range",
        about="Only render the source lines in the given range.",
        short='r',
      ),
      OptionArg("paper", about="Paper size for HTML output (a4|4-3|16-9)."),
    ],
    positionals=[
      PositionArg(
        "input",
        about="Path to the .md input file.",
        num_args=@argparse.ValueRange::single(),
      ),
    ],
  )

  let matches = command.parse()
  guard matches.values.get("input") is Some([input_path]) else {
    return println(command.render_help())
  }
  let input_path = normalize_path(input_path)

  let target = match matches.values.get("target") {
    Some(["html"]) => @notiz.Html
    Some(["html-text"]) => @notiz.HtmlText
    Some(["text"]) => @notiz.Text
    _ => @notiz.Text
  }
  let line_style = match matches.flags.get("ascii") {
    Some(true) => @backend.LineStyle::Ascii
    _ => @backend.LineStyle::Unicode
  }
  let paper_given = matches.values.get("paper") is Some([_])
  let paper = match matches.values.get("paper") {
    Some([value]) =>
      match @backend.Paper::from_string(value) {
        Ok(paper) => paper
        Err(error) => return println("error: \{error}")
      }
    _ => @backend.Paper::A4
  }
  if paper_given {
    match target {
      Text =>
        println(
          "warning: --paper only applies to --target html/html-text; ignored",
        )
      _ => ()
    }
  }
  let ansi = matches.flags.get("ansi") is Some(true)
  let disable_math = matches.flags.get("disable-math") is Some(true)
  let use_cmark = matches.flags.get("cmark") is Some(true)
  let halfwidth_punct = matches.flags.get("halfwidth") is Some(true)
  if use_cmark {
    match matches.values.get("target") {
      Some(["html"]) | Some(["html-text"]) =>
        return println(
          "error: --cmark only renders terminal text; --target html/html-text is not available with cmark yet",
        )
      _ => ()
    }
  }
  let output_path = matches.values
    .get("output")
    .map_or(None, values => Some(values[0]))
  let range = {
    guard matches.values.get("range") is Some([value]) else { None }
    let parsed = parse_range(value) catch {
      error => return println("error: invalid --range \{value}: \{error}")
    }
    Some(parsed)
  }

  if matches.flags.get("watch") is Some(true) {
    watch_and_render(
      input_path,
      target,
      ansi~,
      line_style~,
      disable_math~,
      use_cmark,
      output_path,
      paper~,
      range~,
      halfwidth_punct~,
    )
  } else {
    render_input(
      input_path,
      target,
      ansi~,
      line_style~,
      disable_math~,
      use_cmark,
      output_path,
      paper~,
      range~,
      halfwidth_punct~,
    )
  }
}

///|
async fn watch_and_render(
  input_path : String,
  target : @notiz.CompileTarget,
  ansi~ : Bool,
  line_style~ : @backend.LineStyle,
  disable_math~ : Bool,
  use_cmark : Bool,
  output_path : String?,
  paper~ : @backend.Paper,
  range~ : (Int, Int)?,
  halfwidth_punct~ : Bool,
) -> Unit {
  let input_name = input_path.unsafe_substring(
    start=input_path.rev_find("/").map_or(0, i => i + 1),
    end=input_path.length(),
  )

  let watched_dir = input_path
    .rev_find("/")
    .map_or(".", i => input_path.unsafe_substring(start=0, end=i + 1))

  let ignored_paths = path => path != input_name
  let watcher = @fs.Watcher(watched_dir, ignored_paths~) catch {
    error => return println("error: unable to watch \{input_path}: \{error}")
  }
  defer watcher.close()

  render_input(
    input_path,
    target,
    ansi~,
    line_style~,
    disable_math~,
    use_cmark,
    output_path,
    paper~,
    range~,
    halfwidth_punct~,
  )
  println("watching: \{input_path} (Ctrl-C to stop)")
  for ;; {
    watcher.wait_any() catch {
      error if @async.is_cancellation_error(error) => return
      error => return println("error: stopped watching \{input_path}: \{error}")
    }
    guard output_path is Some(_) else { println("\u001b[2J\u001b[H") }
    render_input(
      input_path,
      target,
      ansi~,
      line_style~,
      disable_math~,
      use_cmark,
      output_path,
      paper~,
      range~,
      halfwidth_punct~,
    )
  }
}

///|
/// Reads the whole file at once, narrowing to `range` when given.
///
/// We deliberately avoid `@io.Reader::read_until` line-by-line reading.
/// Line-at-a-time reading pays a fixed cost per line — one async call, a
/// separator encode, and a `String` allocation, even for lines that are only
/// skipped — to save memory, and that trade only wins on 100MB+ inputs.
/// Markdown never realistically gets there: 1MB of source already renders to
/// a few hundred A4 pages and 10MB is encyclopedic. At those sizes reading
/// the whole file and letting `extract_range` lazily materialize only the
/// selected lines is both faster and still O(range) in memory, so this is
/// the optimal strategy given the input sizes obsmark actually handles.
async fn read_source(input_path : String, range~ : (Int, Int)?) -> String {
  let content = @fs.read_file(input_path).text()
  match range {
    Some((start, end)) => extract_range(content, start, end).join("\n")
    None => content
  }
}

///|
async fn render_input(
  input_path : String,
  target : @notiz.CompileTarget,
  ansi~ : Bool,
  line_style~ : @backend.LineStyle,
  disable_math~ : Bool,
  use_cmark : Bool,
  output_path : String?,
  paper~ : @backend.Paper,
  range~ : (Int, Int)?,
  halfwidth_punct~ : Bool,
) -> Unit {
  let source = read_source(input_path, range~) catch {
    e => return println("error reading file \{input_path}: \{e}")
  }
  let output = if use_cmark {
    @notiz.compile_cmark(
      source,
      cmark_options(),
      line_style~,
      ansi~,
      disable_math~,
      halfwidth_punct~,
    )
  } else {
    @notiz.compile(
      source,
      target~,
      ansi~,
      line_style~,
      disable_math~,
      paper~,
      halfwidth_punct~,
    ) catch {
      error => return println(@parsing.format_parse_error(error, source))
    }
  }
  match output_path {
    None => println(output)
    Some(path) =>
      @fs.write_file(path, output, create_mode=@fs.CreateMode::CreateOrTruncate)
  }
}

///|
/// Options for cmark rendering: the full CommonMark plus extension set.
fn cmark_options() -> @cmark.Options {
  let options = @cmark.Options::empty()
  options.insert(@cmark.enable_tables())
  options.insert(@cmark.enable_footnotes())
  options.insert(@cmark.enable_strikethrough())
  options.insert(@cmark.enable_tasklists())
  options.insert(@cmark.enable_heading_attributes())
  options.insert(@cmark.enable_yaml_style_metadata_blocks())
  options.insert(@cmark.enable_pluses_delimited_metadata_blocks())
  options.insert(@cmark.enable_math())
  options.insert(@cmark.enable_math_everywhere())
  options.insert(@cmark.enable_gfm())
  options.insert(@cmark.enable_definition_list())
  options.insert(@cmark.enable_superscript())
  options.insert(@cmark.enable_subscript())
  options.insert(@cmark.enable_wikilinks())
  options.insert(@cmark.enable_container_extensions())
  options.insert(@cmark.enable_highlight())
  options.insert(@cmark.enable_cjk_friendly_emphasis())
  options
}

///|
fn normalize_path(path : String) -> String {
  path.replace_all(old="\\", new="/")
}