///|
/// A syntax or limit problem located in a specific piece of JSON text.
///
/// Offsets are character offsets into the original input, matching the offsets
/// reported by `Nanaloveyuki/parsec/json`.
pub struct JsonDiagnostic {
  offset : Int
  line : Int
  column : Int
  message : String
  snippet : String
}

///|
/// Convert a character offset into a 1-based `(line, column)` pair.
///
/// Offsets past the end of the input report the position just after the last
/// character, so an unexpected end of input still points somewhere sensible.
pub fn offset_to_line_col(input : String, offset : Int) -> (Int, Int) {
  let mut line = 1
  let mut column = 1
  let mut index = 0
  for ch in input {
    if index >= offset {
      break
    }
    if ch == '\n' {
      line = line + 1
      column = 1
    } else {
      column = column + 1
    }
    index = index + 1
  }
  (line, column)
}

///|
/// Extract the 1-based `line` from `input`, without its terminator.
pub fn line_text(input : String, line : Int) -> String {
  let mut current = 1
  let buf = StringBuilder()
  for ch in input {
    if current > line {
      break
    }
    if ch == '\n' {
      current = current + 1
    } else if current == line {
      buf.write_char(ch)
    }
  }
  buf.to_string()
}

///|
/// Turn a low-level parsec error into a human-readable reason.
pub fn describe_syntax_error(error : @parsec.ParseError) -> String {
  match error {
    UnexpectedEnd(_) => "unexpected end of input"
    Expected(label~, ..) => "expected " + label
    ExpectedAny(labels~, ..) => {
      let buf = StringBuilder()
      buf.write_string("expected one of: ")
      let mut first = true
      for label in labels {
        if !first {
          buf.write_string(", ")
        }
        first = false
        buf.write_string(label)
      }
      buf.to_string()
    }
    NotFollowedBy(label~, ..) => "unexpected " + label
    Context(cause~, ..) => describe_syntax_error(cause)
    EmptyMatchInMany(_) => "repetition matched an empty input"
    EmptyChoice(_) => "no alternative matched"
    UnboundReference(_) => "unbound parser reference"
  }
}

///|
/// Turn a JSON error into a human-readable reason.
///
/// A duplicate key is described without saying where the first one was, because
/// that position needs the input text to work out; `JsonDiagnostic::from_error`
/// appends it. The wording of the reason itself lives here and only here.
pub fn describe_json_error(error : @pjson.JsonError) -> String {
  match error {
    Syntax(error~) => describe_syntax_error(error)
    DuplicateKey(key~, ..) => "duplicate object key " + quote_for_message(key)
    // Depth reads better the other way round: the document is what exceeded the
    // limit, so it is the subject of the sentence rather than the limit.
    Limit(kind=NestingDepth, limit~, ..) =>
      "nesting depth exceeds limit " + limit.to_string()
    Limit(kind~, limit~, ..) =>
      "input exceeds the " +
      describe_limit_kind(kind) +
      " limit of " +
      limit.to_string()
  }
}

///|
/// Where a key was first written, as a trailing parenthetical.
///
/// Knowing only the second spelling of a repeated key tells you where the
/// document went wrong but not what it collided with, which is the part that
/// takes the searching.
fn first_defined_at(input : String, offset : Int) -> String {
  let (line, column) = offset_to_line_col(input, offset)
  " (first defined at line " +
  line.to_string() +
  ", column " +
  column.to_string() +
  ")"
}

///|
/// Human-readable name for a `JsonLimitKind`.
pub fn describe_limit_kind(kind : @pjson.JsonLimitKind) -> String {
  match kind {
    InputChars => "input length"
    NestingDepth => "nesting depth"
    StringChars => "string length"
    ArrayItems => "array length"
    ObjectMembers => "object member count"
  }
}

///|
/// Render `value` as a quoted, escaped literal for use in a message.
///
/// Public because the messages that name keys are not all written here:
/// `flatten.mbt` names the two keys of a conflict, and a key is escaped the
/// same way wherever it is quoted.
pub fn quote_for_message(value : String) -> String {
  "\"" + escape_json_string(value) + "\""
}

///|
/// Build a diagnostic for `error` located in `input`.
pub fn JsonDiagnostic::from_error(
  input : String,
  error : @pjson.JsonError,
) -> JsonDiagnostic {
  JsonDiagnostic::for_record(input, 0, error)
}

///|
/// Build a diagnostic for an error in the record of `whole` that starts `base`
/// characters in.
///
/// The error's offsets are relative to the record rather than to `whole`; both
/// the position and the snippet are reported against `whole`, so a bad record
/// three lines down a JSON Lines file is reported at its real line and quotes
/// its own line of the file. `from_error` is this with nothing before the
/// record, which is the ordinary single-document case.
pub fn JsonDiagnostic::for_record(
  whole : String,
  base : Int,
  error : @pjson.JsonError,
) -> JsonDiagnostic {
  let offset = base + error.offset()
  let (line, column) = offset_to_line_col(whole, offset)
  let message = match error {
    // The parser remembers where the key was first defined, so the report can
    // name it. The caret still marks the repeated one.
    DuplicateKey(first_offset~, ..) =>
      describe_json_error(error) + first_defined_at(whole, base + first_offset)
    _ => describe_json_error(error)
  }
  JsonDiagnostic::{
    offset,
    line,
    column,
    message,
    snippet: line_text(whole, line),
  }
}

///|
/// Build a diagnostic for an explicit offset and message.
pub fn JsonDiagnostic::at(
  input : String,
  offset : Int,
  message : String,
) -> JsonDiagnostic {
  let (line, column) = offset_to_line_col(input, offset)
  JsonDiagnostic::{
    offset,
    line,
    column,
    message,
    snippet: line_text(input, line),
  }
}

///|
/// A one-line summary: `line 3, column 5: expected value`.
///
/// The position is coloured and the reason is not, which is the only division
/// the message offers: the position is where to look and the reason is what to
/// do about it. `yellow` returns its argument unchanged unless colour is on, so
/// this is the same string it has always been when the output is not a
/// terminal — which is what every test compares against.
pub fn JsonDiagnostic::summary(self : JsonDiagnostic) -> String {
  yellow(
    "line " + self.line.to_string() + ", column " + self.column.to_string(),
  ) +
  ": " +
  self.message
}

///|
/// How much of a line a report shows on either side of the column it is
/// reporting.
///
/// A line longer than twice this is elided around the column, which keeps a
/// report a fixed size however long the line is. Without it a document written
/// on one line is reported by printing the whole document, so a mistake near the
/// end of a 10 MB file arrives as 10 MB of terminal.
let snippet_margin : Int = 60

///|
/// The part of `line` a report shows, and where the caret falls inside it.
///
/// The line comes back whole while it is short enough to read, which is almost
/// every line; past that the report keeps `snippet_margin` characters on each
/// side of the reported column — the characters the caret is pointing into are
/// the point, and they are wherever the mistake is — and writes `...` in place
/// of each end it cut. The caret is then counted in what is left rather than in
/// the line, which is what keeps it under the right character.
fn snippet_window(line : String, column : Int) -> (String, Int) {
  let characters = line.char_length()
  let mark = column - 1
  if characters <= snippet_margin * 2 {
    return (line, mark)
  }
  let start = if mark > snippet_margin { mark - snippet_margin } else { 0 }
  let stop = if mark + snippet_margin < characters {
    mark + snippet_margin
  } else {
    characters
  }
  let buf = StringBuilder()
  if start > 0 {
    buf.write_string("...")
  }
  let mut index = 0
  for ch in line {
    if index >= stop {
      break
    }
    if index >= start {
      buf.write_char(ch)
    }
    index = index + 1
  }
  if stop < characters {
    buf.write_string("...")
  }
  let cut = if start > 0 { 3 } else { 0 }
  (buf.to_string(), mark - start + cut)
}

///|
/// A multi-line report: the summary followed by the offending source line and
/// a caret marking the column.
///
/// The line is elided when it is too long to print, the caret keeping its place
/// under the character the column names; the summary still reports the line and
/// column of the original document, which is what a person needs to find it in
/// an editor.
pub fn JsonDiagnostic::render(self : JsonDiagnostic) -> String {
  let buf = StringBuilder()
  buf.write_string(self.summary())
  if self.snippet != "" {
    let (shown, caret) = snippet_window(self.snippet, self.column)
    buf.write_char('\n')
    buf.write_string("  " + shown)
    buf.write_char('\n')
    buf.write_string("  ")
    for _ in 0..