///|
fn main {
  let cmd = @argparse.Command("tim", about="Compile an .alt file to HTML.", positionals=[
    @argparse.PositionArg(
      "input",
      about="Path to the .alt file.",
      num_args=@argparse.ValueRange::single(),
    ),
  ])
  let matches = cmd.parse() catch { err => return println(err) }
  guard matches.values.get("input") is Some([input_path]) else {
    return println(cmd.render_help())
  }

  if !input_path.has_suffix(".alt") {
    return println("error: input path must end with .alt")
  }

  let output_path = output_path_for(input_path)
  guard read_input(input_path) is Some(source) &&
    render_html(input_path, source) is Some(html) &&
    write_output(output_path, html) else {
    return
  }
  return println("output: \{output_path}")
}

///|
fn output_path_for(input_path : String) -> String {
  let end = input_path.length() - 4
  input_path.unsafe_substring(start=0, end~) + ".html"
}

///|
fn render_html(input_path : String, source : String) -> String? {
  let options = {
    ..@backend_html.HtmlDocumentOptions::default(),
    extensions: [html_include_resolver(input_dir(input_path))],
  }
  let result = @backend_html.to_html_document(source, options~)
  if result is Err(error) {
    return {
      println(error.render())
      None
    }
  }
  guard result is Ok(html) else { return None }
  Some(html)
}

///|
fn html_include_resolver(base_dir : String) -> @backend_html.ElementResolver {
  (call, context) => {
    if call.tag != "html.include" {
      return Ok(None)
    }
    let path = match include_path(call, context) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    let resolved = resolve_include_path(base_dir, path)
    match read_include(resolved, call.span) {
      Ok(content) => {
        let raw = @sexp.SexpNode::RawHtml(content)
        Ok(Some(@backend_html.ElementOutput::body([raw])))
      }
      Err(error) => Err(error)
    }
  }
}

///|
fn include_path(
  call : @backend_html.ElementCall,
  context : @backend_html.CompileContext,
) -> Result[String, @core.Diagnostic] {
  for attr in call.attrs {
    if attr.name != "src" {
      continue
    }
    guard attr.value is Some(value) else {
      return Err(
        attr.span.diagnostic(
          @core.DiagnosticPhase::Type,
          "html.include src requires a value",
        ),
      )
    }
    return @core.value_to_scalar(value, context.env)
  }
  guard !call.children.is_empty() else {
    return Err(
      call.span.diagnostic(
        @core.DiagnosticPhase::Type,
        "html.include requires a path",
      ),
    )
  }
  @core.value_to_scalar(@core.nodes_to_value(call.children), context.env)
}

///|
fn read_include(
  path : String,
  span : @core.SourceSpan,
) -> Result[String, @core.Diagnostic] {
  Ok(@fs.read_file_to_string(path)) catch {
    @fs.IOError(msg) =>
      Err(
        span.diagnostic(
          @core.DiagnosticPhase::ContentModel,
          describe_io_error("including", path, msg),
        ),
      )
  }
}

///|
fn resolve_include_path(base_dir : String, path : String) -> String {
  if path.has_prefix("/") || base_dir.is_empty() {
    return path
  }
  base_dir + path
}

///|
fn input_dir(path : String) -> String {
  let chars = path.to_array()
  let mut index = chars.length()
  while index > 0 {
    index -= 1
    if chars[index] == '/' {
      return path.unsafe_substring(start=0, end=index + 1)
    }
  }
  ""
}

///|
fn read_input(path : String) -> String? {
  Some(@fs.read_file_to_string(path)) catch {
    @fs.IOError(msg) => {
      println(describe_io_error("reading", path, msg))
      None
    }
  }
}

///|
fn write_output(path : String, content : String) -> Bool {
  try {
    @fs.write_string_to_file(path, content)
    true
  } catch {
    @fs.IOError(msg) => {
      println(describe_io_error("writing", path, msg))
      false
    }
  }
}

///|
fn describe_io_error(action : String, path : String, msg : String) -> String {
  match () {
    _ if is_file_not_found_error(msg) =>
      "I/O error while \{action}: file not found: \{path}"
    _ if is_permission_error(msg) =>
      "I/O error while \{action}: permission denied: \{path}"
    _ if is_directory_error(msg) =>
      "I/O error while \{action}: expected a file path: \{path}"
    _ => "I/O error while \{action} \{path}: \{msg}"
  }
}

///|
fn is_file_not_found_error(msg : String) -> Bool {
  msg.contains("No such file") ||
  msg.contains("cannot find") ||
  msg.contains("not found")
}

///|
fn is_permission_error(msg : String) -> Bool {
  msg.contains("Permission denied") ||
  msg.contains("Access is denied") ||
  msg.contains("access is denied")
}

///|
fn is_directory_error(msg : String) -> Bool {
  msg.contains("Is a directory") || msg.contains("is a directory")
}