///|
fn do_convert_document(doc : @html.Document, config : Config) -> String {
  let writer = CodeWriter::new(config.indent)
  let ctx : ConvertContext = { config, writer }
  let body_id = doc.body_element
  let children = if body_id >= 0 {
    doc.get_children(body_id)
  } else {
    doc.get_children(0)
  }
  let effective = filter_meaningful_children(doc, children)
  if effective.length() == 1 {
    ctx.convert_node(doc, effective[0])
  } else if effective.length() > 1 {
    ctx.writer
    ..write(ctx.config.module_prefix)
    ..write("fragment([")
    ..newline()
    .indent()
    for i, child_id in effective {
      if i > 0 {
        ctx.writer..write(",").newline()
      }
      ctx.writer.write_indent()
      ctx.convert_node(doc, child_id)
    }
    ctx.writer..newline()..dedent()..write_indent().write("])")
  } else {
    ctx.writer..write(ctx.config.module_prefix).write("nothing")
  }
  ctx.writer.to_string()
}

///|
priv struct ConvertContext {
  config : Config
  writer : CodeWriter
}

///|
fn filter_meaningful_children(
  doc : @html.Document,
  children : Array[Int],
) -> Array[Int] {
  let result : Array[Int] = []
  for id in children {
    match doc.get_node(id) {
      Some(@html.ElementNode(_)) => result.push(id)
      Some(@html.TextNode(_)) => {
        let text = doc.get_text_content(id)
        if !text.trim().is_empty() {
          result.push(id)
        }
      }
      Some(@html.CommentNode(_)) => result.push(id)
      _ => ()
    }
  }
  result
}

///|
fn ConvertContext::convert_node(
  self : ConvertContext,
  doc : @html.Document,
  node_id : Int,
) -> Unit {
  match doc.get_node(node_id) {
    Some(@html.ElementNode(elem)) => self.convert_element(doc, elem, node_id)
    Some(@html.TextNode(text)) => self.convert_text(text)
    Some(@html.CommentNode(text)) => self.convert_comment(text)
    _ => ()
  }
}

///|
fn ConvertContext::convert_text(self : ConvertContext, text : String) -> Unit {
  self.writer.write(escape_moonbit_string(text))
}

///|
fn ConvertContext::convert_comment(
  self : ConvertContext,
  text : String,
) -> Unit {
  if self.config.convert_comments {
    for line in text.split("\n") {
      self.writer..write("// ")..write(line.trim().to_string()).newline()
    }
  }
}

///|
fn ConvertContext::convert_element(
  self : ConvertContext,
  doc : @html.Document,
  elem : @html.Element,
  node_id : Int,
) -> Unit {
  let tag = elem.tag_name.to_lower()
  let tag_info = if self.config.use_named_functions {
    let info = get_tag_info(tag)
    if tag == "a" && self.config.a_tag_mode is Node {
      None
    } else {
      info
    }
  } else {
    None
  }
  let is_void = is_void_element(tag)
  let child_ids = doc.get_children(node_id)
  let meaningful_children = filter_meaningful_children(doc, child_ids)
  let has_children = meaningful_children.length() > 0
  let use_named = tag_info is Some(_)

  let fn_name = match tag_info {
    Some(info) => info.fn_name
    None => "node"
  }

  let filtered_attrs : Array[(String, String)] = []
  let seen : Set[String] = Set::new()
  for pair in elem.attributes {
    let name = pair.0
    let value = pair.1
    if seen.contains(name) {
      continue
    }
    seen.add(name)
    if name == "style" && value.trim().is_empty() {
      continue
    }
    filtered_attrs.push((name, value))
  }

  let sep = separate_attrs(filtered_attrs, tag, tag_info, use_named)

  self.generate_element_code(
    doc,
    tag,
    fn_name,
    use_named,
    is_void,
    has_children,
    meaningful_children,
    sep.named_args,
    sep.fallback_attrs,
    sep.style_decls,
  )
}

///|
priv struct SepAttrs {
  named_args : Array[(String, String, String)]
  fallback_attrs : Array[(String, String)]
  style_decls : Array[StyleDecl]
}

///|
fn separate_attrs(
  attrs : Array[(String, String)],
  tag : String,
  tag_info : TagInfo?,
  use_named : Bool,
) -> SepAttrs {
  let named_args : Array[(String, String, String)] = []
  let fallback_attrs : Array[(String, String)] = []
  let style_decls : Array[StyleDecl] = []
  let tag_params = match tag_info {
    Some(info) => info.named_params
    None => Map::new()
  }

  for entry in attrs {
    let (name, value) = entry
    let kind = classify_attr(name, tag, tag_params)
    if !use_named {
      match kind {
        StyleAttr => {
          let decls = parse_style(value)
          for decl in decls {
            style_decls.push(decl)
          }
        }
        _ => fallback_attrs.push((name, value))
      }
    } else {
      match kind {
        CommonNamedParam(param_name) =>
          named_args.push((param_name, value, "string"))
        TagSpecificNamedParam(param_name) =>
          if param_name == "input_type" {
            named_args.push((param_name, value, "input_type"))
          } else if is_dom_bool_property(name) {
            named_args.push((param_name, value, "bool"))
          } else {
            named_args.push((param_name, value, "string"))
          }
        DomBoolProperty => named_args.push((name, value, "bool"))
        DomStringProperty => named_args.push((name, value, "string"))
        StyleAttr => {
          let decls = parse_style(value)
          for decl in decls {
            style_decls.push(decl)
          }
        }
        FallbackAttribute => fallback_attrs.push((name, value))
        EventHandler => fallback_attrs.push((name, value))
      }
    }
  }
  { named_args, fallback_attrs, style_decls }
}

///|
fn ConvertContext::generate_element_code(
  self : ConvertContext,
  doc : @html.Document,
  tag : String,
  fn_name : String,
  use_named : Bool,
  is_void : Bool,
  has_children : Bool,
  children : Array[Int],
  named_args : Array[(String, String, String)],
  fallback_attrs : Array[(String, String)],
  style_decls : Array[StyleDecl],
) -> Unit {
  let prefix = self.config.module_prefix
  let has_fallback = fallback_attrs.length() > 0
  let has_styles = style_decls.length() > 0

  if use_named {
    self.generate_named_element(
      doc, tag, fn_name, is_void, has_children, children, named_args, fallback_attrs,
      style_decls, prefix, has_fallback, has_styles,
    )
  } else {
    self.generate_node_element(
      doc, tag, children, has_children, fallback_attrs, style_decls, prefix, has_fallback,
      has_styles,
    )
  }
}

///|
fn ConvertContext::generate_named_element(
  self : ConvertContext,
  doc : @html.Document,
  tag : String,
  fn_name : String,
  is_void : Bool,
  has_children : Bool,
  children : Array[Int],
  named_args : Array[(String, String, String)],
  fallback_attrs : Array[(String, String)],
  style_decls : Array[StyleDecl],
  prefix : String,
  has_fallback : Bool,
  has_styles : Bool,
) -> Unit {
  ignore(tag)
  let needs_attrs_chain = has_fallback

  self.writer..write(prefix).write(fn_name)

  let mut has_parens_content = false

  if has_styles {
    self.writer.write("(style=[")
    for i, decl in style_decls {
      if i > 0 {
        self.writer.write(", ")
      }
      self.writer.write(escape_moonbit_string(decl.prop + ":" + decl.value))
    }
    self.writer.write("]")
    has_parens_content = true
  }

  for tuple in named_args {
    let param_name = tuple.0
    let value = tuple.1
    let type_kind = tuple.2
    if !has_parens_content {
      self.writer.write("(")
    } else {
      self.writer.write(", ")
    }
    self.write_named_arg(param_name, value, type_kind)
    has_parens_content = true
  }

  if needs_attrs_chain {
    if !has_parens_content {
      self.writer.write("(")
    } else {
      self.writer.write(", ")
    }
    self.writer..write("attrs=")..write(prefix).write("Attrs::build()")
    for pair in fallback_attrs {
      self.write_attr_chain(pair.0, pair.1)
    }
    has_parens_content = true
  }

  if !is_void && has_children {
    if has_parens_content {
      self.writer.write(")")
    }
    if self.config.use_reverse_pipe {
      self.writer.write(" <| ")
      self.convert_children_compact(doc, children)
    } else {
      self.writer.write("(")
      self.convert_children_inline(doc, children)
      self.writer.write(")")
    }
  } else if is_void && !has_children {
    if has_parens_content {
      self.writer.write(")")
    } else {
      self.writer.write("()")
    }
  } else if is_void && has_children {
    if !has_parens_content {
      self.writer.write("(")
    }
    self.writer.write(", ")
    self.convert_children_inline(doc, children)
    self.writer.write(")")
  } else if has_parens_content {
    self.writer.write(")")
  }
}

///|
fn ConvertContext::generate_node_element(
  self : ConvertContext,
  doc : @html.Document,
  tag : String,
  children : Array[Int],
  has_children : Bool,
  fallback_attrs : Array[(String, String)],
  style_decls : Array[StyleDecl],
  prefix : String,
  has_fallback : Bool,
  has_styles : Bool,
) -> Unit {
  self.writer
  ..write(prefix)
  ..write("node(")
  ..write(escape_moonbit_string(tag))
  ..write(", ")
  ..write(prefix)
  .write("Attrs::build()")
  for pair in fallback_attrs {
    self.write_attr_chain(pair.0, pair.1)
  }
  for decl in style_decls {
    self.writer
    ..write(".style(")
    ..write(escape_moonbit_string(decl.prop))
    ..write(", ")
    ..write(escape_moonbit_string(decl.value))
    .write(")")
  }
  ignore(has_fallback)
  ignore(has_styles)
  if has_children {
    if self.config.use_reverse_pipe {
      self.writer..write(")").write(" <| ")
      self.convert_children_compact(doc, children)
    } else {
      self.writer.write(", ")
      self.convert_children_inline(doc, children)
      self.writer.write(")")
    }
  } else {
    self.writer
    ..write(", ([] : Array[")
    ..write(prefix[:prefix.length() - 1].to_string())
    .write("Html]))")
  }
}

///|
fn ConvertContext::write_named_arg(
  self : ConvertContext,
  param_name : String,
  value : String,
  type_kind : String,
) -> Unit {
  self.writer..write(param_name).write("=")
  if type_kind == "bool" {
    let b = parse_bool_value(value)
    self.writer.write(b.to_string())
  } else if type_kind == "input_type" {
    self.writer
    ..write(self.config.module_prefix)
    ..write("InputType::")
    .write(input_type_from_string(value))
  } else {
    self.writer.write(escape_moonbit_string(value))
  }
}

///|
fn parse_bool_value(s : String) -> Bool {
  match s.to_lower() {
    "" => true
    "true" => true
    _ => false
  }
}

///|
fn input_type_from_string(s : String) -> String {
  match s.to_lower() {
    "button" => "Button"
    "checkbox" => "Checkbox"
    "color" => "Color"
    "date" => "Date"
    "datetime-local" => "DateTimeLocal"
    "email" => "Email"
    "file" => "File"
    "hidden" => "Hidden"
    "image" => "Image"
    "month" => "Month"
    "number" => "Number"
    "password" => "Password"
    "radio" => "Radio"
    "range" => "Range"
    "reset" => "Reset"
    "search" => "Search"
    "submit" => "Submit"
    "tel" => "Tel"
    "text" => "Text"
    "time" => "Time"
    "url" => "Url"
    "week" => "Week"
    _ => "Text"
  }
}

///|
fn ConvertContext::write_attr_chain(
  self : ConvertContext,
  name : String,
  value : String,
) -> Unit {
  match get_attr_method(name) {
    Some(info) => {
      self.writer..write(".")..write(info.method_name).write("(")
      match info.param_type {
        AttrBool => {
          let b = parse_bool_value(value)
          self.writer..write(b.to_string()).write(")")
        }
        AttrInt =>
          try {
            let _ = @string.parse_int(value)
            self.writer..write(value).write(")")
          } catch {
            _ => self.writer..write(escape_moonbit_string(value)).write(")")
          }
        AttrString =>
          self.writer..write(escape_moonbit_string(value)).write(")")
      }
    }
    None =>
      if name.has_prefix("data-") {
        let data_name = name["data-".length():].to_string()
        self.writer
        ..write(".data_set(")
        ..write(escape_moonbit_string(data_name))
        ..write(", ")
        ..write(escape_moonbit_string(value))
        .write(")")
      } else if name.has_prefix("aria-") {
        self.writer
        ..write(".")
        ..write(aria_method_name(name))
        ..write("(")
        ..write(escape_moonbit_string(value))
        .write(")")
      } else {
        self.writer..write("// TODO: unsupported attr: ").write(name)
      }
  }
}

///|
fn aria_method_name(attr_name : String) -> String {
  let suffix = attr_name["aria-".length():].to_string()
  let parts = suffix.split("-")
  let result = StringBuilder::new()
  result.write_string("aria_")
  for i, part in parts {
    if i == 0 {
      result.write_view(part)
    } else if !part.is_empty() {
      result.write_view(part[:1].to_upper())
      result.write_view(part[1:].to_upper())
    }
  }
  result.to_string()
}

///|
fn ConvertContext::convert_children_inline(
  self : ConvertContext,
  doc : @html.Document,
  children : Array[Int],
) -> Unit {
  if children.length() == 0 {
    return
  }
  if is_single_text_child(doc, children) {
    let text = get_text_content(doc, children[0])
    self.writer.write(escape_moonbit_string(text))
    return
  }
  if children.length() == 1 {
    self.convert_node(doc, children[0])
    return
  }
  self.writer.write("[")
  for i, child_id in children {
    if i > 0 {
      self.writer.write(", ")
    }
    self.convert_node(doc, child_id)
  }
  self.writer.write("]")
}

///|
fn ConvertContext::convert_children_compact(
  self : ConvertContext,
  doc : @html.Document,
  children : Array[Int],
) -> Unit {
  if children.length() == 0 {
    return
  }
  if is_single_text_child(doc, children) {
    let text = get_text_content(doc, children[0])
    self.writer.write(escape_moonbit_string(text))
    return
  }
  if children.length() == 1 {
    self.convert_node(doc, children[0])
    return
  }
  self.writer.write("[")
  for i, child_id in children {
    if i > 0 {
      self.writer.write(", ")
    }
    self.convert_node(doc, child_id)
  }
  self.writer.write("]")
}

///|
fn is_single_text_child(doc : @html.Document, children : Array[Int]) -> Bool {
  children is [child] && doc.get_node(child) is Some(@html.TextNode(_))
}

///|
fn get_text_content(doc : @html.Document, node_id : Int) -> String {
  match doc.get_node(node_id) {
    Some(@html.TextNode(text)) => text
    _ => ""
  }
}