// `api format`: the canonical spelling of an `.api` spec (← goctl's `api format`).
//
// The formatter works on lines, not on the parse tree: a spec's comments, its
// group annotations and the exact text of its struct tags are the author's, and a
// round trip through `Spec` would drop every one of them. What it does normalise is
// the layout — one leading pair of spaces per nesting level, single spaces between
// words, `key: value` in a group, one blank line between blocks, one newline at the
// end — which is what makes `format` idempotent and what makes a diff between two
// runs empty.

///|
/// Two spaces per nesting level — the indent a formatted spec uses.
fn indent(depth : Int32) -> String {
  let mut out = ""
  let mut i = 0
  while i < depth {
    out = out + "  "
    i = i + 1
  }
  out
}

///|
/// `line` with its trailing whitespace cut and every leading tab read as two
/// spaces, so a spec indented in an editor that inserts tabs still formats to the
/// same text as one indented with spaces. Tabs *inside* a line are left alone: a
/// struct tag or a quoted summary may legitimately carry one.
fn normalize_leading_ws(line : String) -> String {
  let n = line.length()
  let mut end = n
  while end > 0 {
    let c = line[end - 1]
    if @text.is_ws(c) {
      end = end - 1
    } else {
      break
    }
  }
  let mut start = 0
  let mut out = ""
  while start < end && (line[start] == ' ' || line[start] == '\t') {
    if line[start] == '\t' {
      out = out + "  "
    } else {
      out = out + " "
    }
    start = start + 1
  }
  out + line[start:end].to_owned()
}

///|
/// Split a line into its code and its `//` comment (comment included as written,
/// empty when there is none). A `//` inside a quoted summary or a back-tick tag is
/// part of the code, not the start of a comment.
fn split_comment(line : String) -> (String, String) {
  let n = line.length()
  let mut in_str = false
  let mut in_tag = false
  for i = 0; i < n; i = i + 1 {
    let c = line[i]
    if c == '"' && in_tag == false {
      in_str = in_str == false
    } else if c == '`' && in_str == false {
      in_tag = in_tag == false
    } else if c == '/' &&
      in_str == false &&
      in_tag == false &&
      i + 1 < n &&
      line[i + 1] == '/' {
      return (line[0:i].to_owned(), line[i:].to_owned())
    }
  }
  (line, "")
}

///|
/// Collapse every run of whitespace outside a quoted string and a back-tick tag
/// into one space, and put that space where the language wants it: after a comma
/// and after a colon, never before one, never inside `()`, and always before `{`.
fn collapse(code : String) -> String {
  let n = code.length()
  let mut out = ""
  let mut i = 0
  let mut in_str = false
  let mut in_tag = false
  let mut pending = false
  let mut force = false
  let mut path_param = false
  while i < n {
    let c = code[i]
    if in_str == false && in_tag == false && @text.is_ws(c) {
      pending = true
      i = i + 1
      continue
    }
    if in_str == false && in_tag == false {
      let last = last_char(out)
      let want = (pending || force) &&
        (path_param == false || is_word_char(c) == false) &&
        out.length() > 0 &&
        last != '(' &&
        last != ' ' &&
        c != ')' &&
        c != ',' &&
        c != ':' &&
        c != '}'
      if want {
        out = out + " "
      }
      if c == '{' && last_char(out) != ' ' && last_char(out) != '{' {
        out = out + " "
      }
    }
    let before = last_char(out)
    out = out + code[i:i + 1].to_owned()
    if in_str == false && in_tag == false {
      // `key:value` in a group wants the space; `:id` in a path does not. A
      // colon right after a slash names a path parameter, and a space there
      // would turn `/profile/:id` into a route that matches nothing.
      if c == ':' {
        if before == '/' {
          path_param = true
        } else {
          force = true
        }
      } else {
        force = false
        if path_param && is_word_char(c) == false {
          path_param = false
        }
      }
      // a comma is followed by one space, the way a group lists its settings
      if c == ',' {
        pending = true
      }
    }
    if c != ',' {
      pending = false
    }
    if c == '"' && in_tag == false {
      in_str = in_str == false
    } else if c == '`' && in_str == false {
      in_tag = in_tag == false
    }
    i = i + 1
  }
  out
}

///|
/// `key:value` with the one space the language asks for; used inside a group.
fn normalize_pair(piece : String) -> String {
  let t = @text.trim(piece)
  let colon = index_of(t, ':', 0)
  if colon <= 0 {
    return t
  }
  let key = @text.trim(t[0:colon].to_owned())
  let value = @text.trim(t[colon + 1:].to_owned())
  if value == "" {
    key + ":"
  } else {
    key + ": " + value
  }
}

///|
/// Rebuild a `@server( … )` line as `@server(key: value, key: value)`, splitting on
/// the commas outside a quoted value. A line that does not close its parenthesis on
/// the same line keeps the open paren bare, so a multi-line group indents cleanly.
fn normalize_server(t : String) -> String {
  let open = index_of(t, '(', 0)
  if open < 0 {
    return t
  }
  let close = index_of(t, ')', open + 1)
  if close < 0 {
    return "@server ("
  }
  let body = @text.trim(t[open + 1:close].to_owned())
  let pieces : Array[String] = []
  if body != "" {
    for piece in split_commas(body) {
      let p = normalize_pair(piece)
      if p != "" {
        pieces.push(p)
      }
    }
  }
  "@server (" + join_with(pieces, ", ") + ")" + t[close + 1:].to_owned()
}

///|
/// `parts` joined with `sep`.
fn join_with(parts : Array[String], sep : String) -> String {
  let mut out = ""
  for i = 0; i < parts.length(); i = i + 1 {
    if i > 0 {
      out = out + sep
    }
    out = out + parts[i]
  }
  out
}

///|
/// The canonical spelling of one line of a spec: whitespace collapsed, `@server`
/// rebuilt, and the author's comment re-attached after the code.
fn normalize_line(line : String) -> String {
  let (code, comment) = split_comment(line)
  let mut body = collapse(@text.trim(code))
  if @text.starts_with(body, "@server") {
    body = normalize_server(body)
  }
  if comment == "" {
    body
  } else if body == "" {
    @text.trim(comment)
  } else {
    body + " " + @text.trim(comment)
  }
}

///|
/// The last character of `s`, or `' '` when it has none.
fn last_char(s : String) -> UInt16 {
  if s.length() == 0 {
    ' '
  } else {
    s[s.length() - 1]
  }
}

///|
/// Whether `c` may appear in a name — the letters, digits and underscore a path
/// parameter is spelled with.
fn is_word_char(c : UInt16) -> Bool {
  (c >= 'a' && c <= 'z') ||
  (c >= 'A' && c <= 'Z') ||
  (c >= '0' && c <= '9') ||
  c == '_'
}

///|
/// Format an `.api` source. Nesting is read off braces and off the two blocks that
/// open with a parenthesis — a multi-line `@server (` and a `type (` group; blank
/// lines collapse to one; the result ends in exactly one newline. Comments and
/// struct tags survive verbatim, and the function is idempotent: formatting an
/// already formatted spec returns it unchanged.
pub fn format(source : String) -> String {
  let out : Array[String] = []
  let mut depth = 0
  let mut blank = false
  let mut in_server = false
  let mut in_type = false
  for raw in lines(source) {
    let line = normalize_leading_ws(raw)
    let text = normalize_line(line)
    if text == "" {
      if out.length() > 0 {
        blank = true
      }
      continue
    }
    if blank && out.length() > 0 {
      out.push("")
      blank = false
    }
    let closes = @text.starts_with(text, "}") ||
      (in_server && text == ")") ||
      (in_type && text == ")")
    let d = if closes && depth > 0 { depth - 1 } else { depth }
    out.push(indent(d) + text)
    if closes {
      if depth > 0 {
        depth = depth - 1
      }
      if in_server && text == ")" {
        in_server = false
      }
      if in_type && text == ")" {
        in_type = false
      }
    }
    if last_char(text) == '{' {
      depth = depth + 1
    } else if text == "@server (" {
      in_server = true
      depth = depth + 1
    } else if text == "type (" {
      in_type = true
      depth = depth + 1
    }
  }
  let mut result = ""
  for i = 0; i < out.length(); i = i + 1 {
    if i > 0 {
      result = result + "\n"
    }
    result = result + out[i]
  }
  result + "\n"
}