///|
pub(all) struct Heading {
  level : Int
  title : String
  line : Int
  end_line : Int
  path : Array[String]
} derive(Debug, Eq, ToJson)

///|
pub(all) struct Section {
  heading : Heading
  content : String
} derive(Debug, Eq, ToJson)

///|
pub(all) enum ToolOutput {
  Stdout(String)
  Stderr(String)
} derive(Debug, Eq, ToJson)

///|
pub(all) enum CliCommand {
  Toc(String)
  Section(String, String)
  ParseOutput(ToolOutput)
} derive(Debug, Eq, ToJson)

///|
priv enum Selector {
  Text(String)
  Line(Int)
  Range(Int, Int)
}

///|
pub fn read_toc(markdown : String) -> Array[Heading] {
  let lines = split_lines(markdown)
  let headings = cmark_headings(markdown)
  for i in 0.. Array[Heading] {
  let doc = @cmark.Doc::from_string(markdown, locs=true)
  let headings : Array[Heading] = []
  let stack : Array[String] = []
  collect_cmark_headings(doc.block, headings, stack)
  headings
}

///|
fn collect_cmark_headings(
  block : @cmark.Block,
  headings : Array[Heading],
  stack : Array[String],
) -> Unit {
  match block {
    Heading(node) => {
      let level = node.v.level
      let title = inline_plain_text(node.v.inline)
      while stack.length() >= level {
        ignore(stack.pop())
      }
      stack.push(title)
      headings.push({
        level,
        title,
        line: first_line_number(node.meta.loc),
        end_line: first_line_number(node.meta.loc),
        path: stack.copy(),
      })
    }
    Blocks(node) =>
      for child in node.v.iter() {
        collect_cmark_headings(child, headings, stack)
      }
    BlockQuote(node) => collect_cmark_headings(node.v.block, headings, stack)
    List(node) =>
      for item in node.v.items.iter() {
        collect_cmark_headings(item.v.block, headings, stack)
      }
    ExtFootnoteDefinition(node) =>
      collect_cmark_headings(node.v.block, headings, stack)
    _ => ()
  }
}

///|
fn inline_plain_text(inline : @cmark.Inline) -> String {
  let out = StringBuilder::new()
  let mut first_line = true
  for line in inline.to_plain_text(break_on_soft=true).iter() {
    if first_line {
      first_line = false
    } else {
      out.write_string(" ")
    }
    for part in line.iter() {
      out.write_string(part)
    }
  }
  out.to_string()
}

///|
fn first_line_number(loc : @cmark_base.TextLoc) -> Int {
  match loc.first_line {
    LinePos(line, _) => line
  }
}

///|
pub fn render_toc(markdown : String, file_path? : String) -> String {
  let out = StringBuilder::new()
  for heading in read_toc(markdown) {
    for _ in 1.. Section? {
  get_section_with_selector(markdown, parse_selector(query))
}

///|
pub fn render_section(
  markdown : String,
  file_path : String,
  query : String,
) -> String? {
  match get_section(markdown, query) {
    Some(section) => {
      let out = StringBuilder::new()
      out.write_string(
        render_reference(
          Some(file_path),
          section.heading.line,
          section.heading.end_line,
        ),
      )
      out.write_string(" ")
      out.write_string(section.heading.path.join("/"))
      out.write_string("\n\n")
      out.write_string(section.content)
      Some(out.to_string())
    }
    None => None
  }
}

///|
pub fn parse_command(argv : Array[String]) -> CliCommand {
  let parser = cli_parser()
  try parser.parse(argv=argv[1:], env={}) catch {
    err => {
      let message = err.to_string()
      if message.has_prefix("Usage:") {
        ParseOutput(Stdout(message))
      } else {
        ParseOutput(Stderr(message))
      }
    }
  } noraise {
    parsed => command_from_matches(parsed)
  }
}

///|
pub fn toc_tool(file_path : String, markdown : String) -> ToolOutput {
  Stdout(render_toc(markdown, file_path~))
}

///|
pub fn section_tool(
  file_path : String,
  markdown : String,
  query : String,
) -> ToolOutput {
  match render_section(markdown, file_path, query) {
    Some(section) => Stdout(section)
    None => Stderr("section not found: \{query}\n")
  }
}

///|
fn cli_parser() -> @argparse.Command {
  @argparse.Command(
    "md-outline",
    about="Read Markdown outlines and sections.",
    subcommand_required=true,
    arg_required_else_help=true,
    subcommands=[
      @argparse.Command(
        "toc",
        about="Print outline headings with source line ranges.",
        positionals=[
          @argparse.PositionArg(
            "file",
            about="Markdown file to inspect.",
            num_args=@argparse.ValueRange::single(),
          ),
        ],
      ),
      @argparse.Command(
        "section",
        about="Print a section selected by outline path or line reference.",
        positionals=[
          @argparse.PositionArg(
            "file",
            about="Markdown file to inspect.",
            num_args=@argparse.ValueRange::single(),
          ),
          @argparse.PositionArg(
            "query",
            about="Outline path, heading title, start line, or line range.",
            num_args=@argparse.ValueRange(lower=1),
            allow_hyphen_values=true,
          ),
        ],
      ),
    ],
  )
}

///|
fn command_from_matches(matches : @argparse.Matches) -> CliCommand {
  match matches.subcommand {
    Some(("toc", sub)) =>
      match single_value(sub, "file") {
        Some(file) => Toc(file)
        None => ParseOutput(Stderr(cli_parser().render_help()))
      }
    Some(("section", sub)) =>
      match (single_value(sub, "file"), values(sub, "query")) {
        (Some(file), Some(query)) => Section(file, query.join(" "))
        _ => ParseOutput(Stderr(cli_parser().render_help()))
      }
    _ => ParseOutput(Stderr(cli_parser().render_help()))
  }
}

///|
fn single_value(matches : @argparse.Matches, name : StringView) -> String? {
  match matches.values.get_from_string(name) {
    Some(values) =>
      match values.get(0) {
        Some(value) => Some(value)
        None => None
      }
    None => None
  }
}

///|
fn values(matches : @argparse.Matches, name : StringView) -> Array[String]? {
  matches.values.get_from_string(name)
}

///|
fn get_section_with_selector(
  markdown : String,
  selector : Selector,
) -> Section? {
  let headings = read_toc(markdown)
  let mut index = -1
  for i in 0.. Bool {
  match selector {
    Text(wanted) => heading.title == wanted || heading.path.join("/") == wanted
    Line(line) => heading.line <= line && line <= heading.end_line
    Range(start_line, end_line) =>
      heading.line == start_line && heading.end_line == end_line
  }
}

///|
fn parse_selector(query : String) -> Selector {
  let raw = query.trim().to_owned()
  let text = strip_reference(raw)
  match text.split_once("-") {
    Some((start, end)) =>
      match
        (
          parse_positive_int(start.to_owned()),
          parse_positive_int(end.to_owned()),
        ) {
        (Some(start_line), Some(end_line)) if start_line <= end_line =>
          Range(start_line, end_line)
        _ => Text(text)
      }
    None =>
      match parse_positive_int(text) {
        Some(line) => Line(line)
        None => Text(text)
      }
  }
}

///|
fn strip_reference(raw : String) -> String {
  let mut text = raw
  if text.has_prefix("[") && text.has_suffix("]") && text.length() >= 2 {
    text = text.unsafe_substring(start=1, end=text.length() - 1)
  }
  match text.rev_split_once(":") {
    Some((_, suffix)) => suffix.to_owned()
    None => text
  }
}

///|
fn parse_positive_int(text : String) -> Int? {
  if text.is_empty() {
    None
  } else {
    let mut value = 0
    for ch in text {
      if ch < '0' || ch > '9' {
        return None
      }
      value = value * 10 + (ch.to_int() - '0'.to_int())
    }
    Some(value)
  }
}

///|
fn render_reference(
  file_path : String?,
  start_line : Int,
  end_line : Int,
) -> String {
  match file_path {
    Some(path) => "[\{path}:\{start_line}-\{end_line}]"
    None => "[line:\{start_line}-\{end_line}]"
  }
}

///|
fn split_lines(text : String) -> Array[String] {
  let lines : Array[String] = []
  let mut start = 0
  for offset, ch in text.iter2() {
    if ch == '\n' {
      lines.push(text.unsafe_substring(start~, end=offset + 1))
      start = offset + 1
    }
  }
  if start < text.length() {
    lines.push(text.unsafe_substring(start~, end=text.length()))
  }
  lines
}