///|
/// A parsed template, as produced by `parse_template`.
pub type Template = Array[TemplateNode]

// @block template-node

///|
/// A single node in a parsed template's AST.
pub(all) enum TemplateNode {
  /// A literal, verbatim text fragment.
  Text(String)
  /// A variable reference, to be substituted with its value from the
  /// rendering context.
  Variable(String)
  /// A conditional block: the variable name to test, the nodes to render
  /// when it is truthy, and the nodes to render otherwise.
  If(String, Array[TemplateNode], Array[TemplateNode])
  /// A loop block: the variable name (expected to hold a `Value::Array`)
  /// to iterate over, and the nodes to render once per element.
  For(String, Array[TemplateNode])
  /// A reference to another template file to be rendered inline at this
  /// position.
  Partial(String)
} derive(Eq, Debug)
// @end

// @block template-parse-error

///|
/// Error raised when a template's source cannot be parsed into a valid
/// template AST. The associated `String` carries a human-readable
/// description of the parse failure.
pub suberror TemplateParseError {
  TemplateParseError(String)
} derive(Eq, Debug)
// @end

///|
pub impl Show for TemplateParseError with fn to_string(
  self : TemplateParseError,
) -> String {
  match self {
    TemplateParseError(s) => "TemplateParseError: \{s}"
  }
}

///|
/// A named set of template variables, as passed to `apply_template` /
/// `apply_template_strict`.
pub type Context = Map[String, Value]

// @block value

///|
/// A dynamically-typed value available to templates as part of their
/// rendering context.
pub(all) enum Value {
  String(String)
  Number(Double)
  Bool(Bool)
  Array(Array[Value])
  Object(Map[String, Value])
} derive(Eq, Debug)
// @end

///|
/// Formats this value for display, e.g. when substituting a `Value`
/// directly into rendered template output.
pub impl Show for Value with fn to_string(self : Value) -> String {
  let to_string_spec = (v : Value) => {
    match v {
      String(s) => "\"\{s}\""
      _ => v.to_string()
    }
  }
  match self {
    String(s) => s
    Number(n) => n.to_string()
    Bool(b) => b.to_string()
    Array(a) => "[\{a.map(v => v |> to_string_spec).join(", ")}]"
    Object(x) => {
      let output = StringBuilder::new()
      output.write_string("{")
      let mut is_first = true
      for item in x {
        if is_first {
          is_first = false
        } else {
          output.write_string(", ")
        }
        output.write_string(item.0)
        output.write_string(": ")
        output.write_string(item.1 |> to_string_spec)
      }
      output.write_string("}")
      output.to_string()
    }
  }
}

///|
/// Converts a `Json` value into its corresponding `Value` representation,
/// e.g. for constructing template variables from JSON data (see
/// `Summary::flatten`).
pub fn Value::from_json(json : Json) -> Value {
  match json {
    String(s) => String(s)
    Null => String("")
    Number(n, ..) => Number(n)
    True => Bool(true)
    False => Bool(false)
    Array(a) => Array(a.map(v => Value::from_json(v)))
    Object(o) => Object(o.map((_, v) => Value::from_json(v)))
  }
}

///|
/// Parses `input` (any `Show`-able value, typically a `String` of raw
/// template source) into a template AST.
///
/// Raises `TemplateParseError` if `input` is not valid template syntax.
pub fn[T : Show] parse_template(input : T) -> Template raise TemplateParseError {
  parse_string(input.to_string())
}

///|
fn parse_string(input : String) -> Template raise TemplateParseError {
  let (nodes, index, terminator) = parse_nodes(input, 0, [])
  match terminator {
    Some(name) =>
      raise TemplateParseError(
        "unexpected $\{name}$ at byte " + index.to_string(),
      )
    None => nodes
  }
}

///|
fn parse_nodes(
  input : String,
  start : Int,
  terminators : Array[String],
) -> (Array[TemplateNode], Int, String?) raise TemplateParseError {
  let nodes : Array[TemplateNode] = []
  let mut index = start
  while index < input.length() {
    match find_char(input, '$', index) {
      Some(open) => {
        if open > index {
          nodes.push(Text(substring(input, index, open)))
        }
        match find_char(input, '$', open + 1) {
          Some(close) => {
            let tag = substring(input, open + 1, close).trim().to_owned()
            if contains_string(terminators, tag) {
              return (nodes, close + 1, Some(tag))
            } else if tag.has_prefix("if(") && tag.has_suffix(")") {
              let name = trim_tag_argument(tag, "if(")
              let (then_children, next, found) = parse_nodes(input, close + 1, [
                "else", "endif",
              ])
              match found {
                Some("endif") => {
                  nodes.push(If(name, then_children, []))
                  index = next
                }
                Some("else") => {
                  let (else_children, else_next, else_found) = parse_nodes(
                    input,
                    next,
                    ["endif"],
                  )
                  match else_found {
                    Some(_) => {
                      nodes.push(If(name, then_children, else_children))
                      index = else_next
                    }
                    None =>
                      raise TemplateParseError(
                        "missing $endif$ for $if(" +
                        name +
                        ")$ at byte " +
                        open.to_string(),
                      )
                  }
                }
                Some(other) =>
                  raise TemplateParseError(
                    "unexpected $\{other}$ at byte " + open.to_string(),
                  )
                None =>
                  raise TemplateParseError(
                    "missing $endif$ for $if(" +
                    name +
                    ")$ at byte " +
                    open.to_string(),
                  )
              }
            } else if tag.has_prefix("for(") && tag.has_suffix(")") {
              let name = trim_tag_argument(tag, "for(")
              let (children, next, found) = parse_nodes(input, close + 1, [
                "endfor",
              ])
              match found {
                Some(_) => {
                  nodes.push(For(name, children))
                  index = next
                }
                None =>
                  raise TemplateParseError(
                    "missing $endfor$ for $for(" +
                    name +
                    ")$ at byte " +
                    open.to_string(),
                  )
              }
            } else if tag.has_prefix("partial(") && tag.has_suffix(")") {
              nodes.push(Partial(parse_partial_path(tag, open)))
              index = close + 1
            } else if tag == "else" || tag == "endif" || tag == "endfor" {
              raise TemplateParseError(
                "unexpected $\{tag}$ at byte " + open.to_string(),
              )
            } else if is_identifier(tag) {
              nodes.push(Variable(tag))
              index = close + 1
            } else {
              raise TemplateParseError(
                "invalid template tag $\{tag}$ at byte " + open.to_string(),
              )
            }
          }
          None =>
            raise TemplateParseError(
              "unterminated template tag at byte " + open.to_string(),
            )
        }
      }
      None => {
        nodes.push(Text(substring(input, index, input.length())))
        index = input.length()
      }
    }
  }
  (nodes, index, None)
}

///|
fn trim_tag_argument(
  tag : String,
  prefix : String,
) -> String raise TemplateParseError {
  let name = substring(tag, prefix.length(), tag.length() - 1).trim().to_owned()
  if is_identifier(name) {
    name
  } else {
    raise TemplateParseError("invalid template identifier `\{name}`")
  }
}

///|
fn parse_partial_path(
  tag : String,
  offset : Int,
) -> String raise TemplateParseError {
  let raw = substring(tag, "partial(".length(), tag.length() - 1)
    .trim()
    .to_owned()
  if raw.length() >= 2 &&
    char_at(raw, 0) == '"' &&
    char_at(raw, raw.length() - 1) == '"' {
    substring(raw, 1, raw.length() - 1)
  } else {
    raise TemplateParseError(
      "partial expects a quoted path at byte " + offset.to_string(),
    )
  }
}

///|
fn is_identifier(input : String) -> Bool {
  if input == "" {
    false
  } else {
    let first = char_at(input, 0)
    if !is_identifier_start(first) {
      false
    } else {
      let mut index = 1
      while index < input.length() {
        if !is_identifier_continue(char_at(input, index)) {
          return false
        }
        index = index + 1
      }
      true
    }
  }
}

///|
fn is_identifier_start(ch : Char) -> Bool {
  (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch == '_'
}

///|
fn is_identifier_continue(ch : Char) -> Bool {
  is_identifier_start(ch) || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.'
}

///|
fn contains_string(items : Array[String], target : String) -> Bool {
  for item in items {
    if item == target {
      return true
    }
  }
  false
}

///|
fn find_char(input : String, needle : Char, start : Int) -> Int? {
  let mut index = start
  while index < input.length() {
    if char_at(input, index) == needle {
      return Some(index)
    }
    index = index + 1
  }
  None
}

///|
fn substring(input : String, start : Int, end : Int) -> String {
  let output = StringBuilder::new()
  let mut index = start
  while index < end && index < input.length() {
    output.write_char(char_at(input, index))
    index = index + 1
  }
  output.to_string()
}

///|
fn char_at(input : String, index : Int) -> Char {
  input.code_unit_at(index).unsafe_to_char()
}

// @block template-render-error

///|
/// Errors that can occur while rendering a parsed template against a
/// variable context, as raised by `apply_template_strict`.
pub suberror TemplateRenderError {
  /// A variable referenced by the template was not present in the
  /// rendering context.
  UndefinedVariableError(String)
  /// A `for` loop referenced a variable whose value was not a `Value::Array`.
  NonArrayForLoopError(String)
  /// A referenced partial template could not be loaded (e.g. its file
  /// could not be read).
  PartialLoadError(String)
  /// A referenced partial template could not be parsed.
  PartialParseError(String)
  /// A partial template referenced itself, directly or transitively,
  /// resulting in unbounded recursion.
  RecursivePartialError(String)
} derive(Eq, Debug)
// @end

///|
impl Show for TemplateRenderError with fn to_string(self : TemplateRenderError) -> String {
  match self {
    UndefinedVariableError(name) =>
      "UndefinedVariableError: missing variable: \{name}"
    NonArrayForLoopError(name) =>
      "NonArrayForLoopError: expected array for loop: \{name}"
    PartialLoadError(path) => "PartialLoadError: failed to read partial \{path}"
    PartialParseError(path) =>
      "PartialParseError: failed to parse partial \{path}"
    RecursivePartialError(path) =>
      "RecursivePartialError: recursive partial: \{path}"
  }
}

///|
/// Renders a parsed template against the given variable context,
/// producing the final output string.
///
/// Rendering errors (e.g. an undefined variable reference, or a failed
/// partial) are tolerated on a per-node basis: the offending template
/// node is simply rendered as empty output, and rendering continues for
/// the rest of the template, rather than the whole render failing. Use
/// `apply_template_strict` if such errors should instead abort rendering
/// entirely.
pub fn apply_template(template : Template, context : Context) -> String {
  let output = StringBuilder::new()
  render_nodes(template, context, false, output, []) catch {
    _ => panic()
  }
  output.to_string()
}

///|
/// Like `apply_template`, but raises `TemplateRenderError` as soon as any
/// node fails to render — e.g. an undefined variable, a non-array value
/// used in a `for` loop, or a problem loading/parsing a referenced partial
/// — instead of tolerating the failure by rendering that node as empty
/// output.
pub fn apply_template_strict(
  template : Template,
  context : Context,
) -> String raise TemplateRenderError {
  let output = StringBuilder::new()
  render_nodes(template, context, true, output, [])
  output.to_string()
}

///|
fn render_nodes(
  nodes : Array[TemplateNode],
  context : Context,
  strict : Bool,
  output : StringBuilder,
  partial_stack : Array[String],
) -> Unit raise TemplateRenderError {
  for node in nodes {
    match node {
      Text(text) => output.write_string(text)
      Variable(name) =>
        match lookup_value(name, context) {
          Some(value) => output.write_string(value.to_string())
          None =>
            if strict {
              raise UndefinedVariableError("missing variable: " + name)
            }
        }
      If(name, then_children, else_children) =>
        if lookup_value(name, context) is Some(value) && value.is_truthy() {
          render_nodes(then_children, context, strict, output, partial_stack)
        } else {
          render_nodes(else_children, context, strict, output, partial_stack)
        }
      For(name, children) =>
        match lookup_value(name, context) {
          Some(items) if items is Array(items) =>
            for item in items {
              let item_context = context.copy()
              item_context["item"] = item
              render_nodes(
                children, item_context, strict, output, partial_stack,
              )
            }
          _ =>
            if strict {
              raise NonArrayForLoopError("expected array for loop: " + name)
            }
        }
      Partial(path) =>
        render_partial(path, context, strict, output, partial_stack)
    }
  }
}

///|
fn Value::is_truthy(self : Value) -> Bool {
  match self {
    Bool(false) => false
    String("") => false
    Array(items) => items.length() > 0
    _ => true
  }
}

///|
fn lookup_value(path : String, context : Context) -> Value? {
  let parts = split_path(path)
  if parts.length() == 0 || parts[0] == "" {
    None
  } else {
    match context.get(parts[0]) {
      Some(value) => lookup_value_part(value, parts, 1)
      None => None
    }
  }
}

///|
fn lookup_value_part(
  value : Value,
  parts : Array[String],
  index : Int,
) -> Value? {
  if index >= parts.length() {
    Some(value)
  } else if parts[index] == "" {
    None
  } else {
    match value {
      Object(fields) =>
        match fields.get(parts[index]) {
          Some(next) => lookup_value_part(next, parts, index + 1)
          None => None
        }
      _ => None
    }
  }
}

///|
fn split_path(path : String) -> Array[String] {
  let parts : Array[String] = []
  for part in path.split(".").to_array() {
    parts.push(part.to_owned())
  }
  parts
}

///|
fn render_partial(
  path : String,
  context : Context,
  strict : Bool,
  output : StringBuilder,
  partial_stack : Array[String],
) -> Unit raise TemplateRenderError {
  if contains_string(partial_stack, path) {
    raise RecursivePartialError("recursive partial: " + path)
  }
  let source = @fs.read_file_to_string(path) catch {
    @fs.IOError(message) =>
      raise PartialLoadError("failed to read partial `\{path}`: " + message)
  }
  let template = parse_template(source) catch {
    TemplateParseError(message) =>
      raise PartialParseError("failed to parse partial `\{path}`: " + message)
  }
  let next_stack = partial_stack.copy()
  next_stack.push(path)
  render_nodes(template, context, strict, output, next_stack)
}