///|
/// Options for human-readable source reports.
pub struct RenderConfig {
  context_lines : Int
  tab_width : Int
  show_source_name : Bool
  show_code : Bool
} derive(Eq, Debug)

///|
pub fn RenderConfig::default() -> RenderConfig {
  { context_lines: 2, tab_width: 4, show_source_name: true, show_code: true }
}

///|
pub fn RenderConfig::new(
  context_lines? : Int = 2,
  tab_width? : Int = 4,
  show_source_name? : Bool = true,
  show_code? : Bool = true,
) -> RenderConfig {
  {
    context_lines: if context_lines < 0 {
      0
    } else {
      context_lines
    },
    tab_width: if tab_width < 1 {
      1
    } else {
      tab_width
    },
    show_source_name,
    show_code,
  }
}

///|
fn write_repeated(
  output : StringBuilder,
  character : Char,
  count : Int,
) -> Unit {
  let mut index = 0
  while index < count {
    output.write_char(character)
    index += 1
  }
}

///|
fn decimal_width(value : Int) -> Int {
  let mut number = if value < 0 { -value } else { value }
  let mut width = 1
  while number >= 10 {
    number /= 10
    width += 1
  }
  width
}

///|
fn write_line_number(output : StringBuilder, line : Int, width : Int) -> Unit {
  let text = "\{line + 1}"
  write_repeated(output, ' ', width - text.length())
  output.write_string(text)
}

///|
fn label_touches_line(source : Source, label : Label, line : Int) -> Bool {
  match source.line_span(line) {
    None => false
    Some(line_span) =>
      if label.span().is_empty() {
        let location = source.location(label.span().start())
        location.line() == line
      } else {
        label.span().start() < line_span.end() + 1 &&
        label.span().end() > line_span.start()
      }
  }
}

///|
fn label_columns(
  source : Source,
  label : Label,
  line : Int,
  display : DisplayConfig,
) -> (Int, Int) {
  let line_span = source.line_span(line).unwrap()
  let text = source.line_text(line).unwrap()
  let span = label.span()
  let byte_start = if span.start() > line_span.start() {
    span.start() - line_span.start()
  } else {
    0
  }
  let byte_end = if span.end() < line_span.end() {
    span.end() - line_span.start()
  } else {
    line_span.length()
  }
  let start = display_column(text, byte_start, config=display)
  let end = display_column(text, byte_end, config=display)
  (start, if end > start { end } else { start + 1 })
}

///|
fn source_labels(diagnostic : Diagnostic, source_id : SourceId) -> Array[Label] {
  diagnostic.labels().filter(fn(label) { label.source() == source_id })
}

///|
fn source_windows(
  source : Source,
  labels : Array[Label],
  context_lines : Int,
) -> Array[LineWindow] {
  let windows = labels.map(fn(label) {
    source.window(label.span(), context_lines~)
  })
  merge_windows(windows)
}

///|
fn render_annotation(
  output : StringBuilder,
  source : Source,
  labels : Array[Label],
  line : Int,
  number_width : Int,
  display : DisplayConfig,
) -> Unit {
  let active = labels.filter(fn(label) {
    label_touches_line(source, label, line)
  })
  for label in active {
    let (start, end) = label_columns(source, label, line, display)
    write_repeated(output, ' ', number_width)
    output.write_string(" | ")
    write_repeated(output, ' ', start)
    write_repeated(
      output,
      if label.is_primary() {
        '^'
      } else {
        '-'
      },
      end - start,
    )
    if label.message().length() > 0 {
      output.write_char(' ')
      output.write_string(label.message())
    }
    output.write_char('\n')
  }
}

///|
fn render_source_block(
  output : StringBuilder,
  source : Source,
  source_id : SourceId,
  labels : Array[Label],
  config : RenderConfig,
) -> Unit {
  let display = DisplayConfig::new(config.tab_width, 1)
  let windows = source_windows(source, labels, config.context_lines)
  let number_width = decimal_width(source.line_count())
  if config.show_source_name {
    let first = labels[0]
    let location = source.location(first.span().start())
    write_repeated(output, ' ', number_width)
    output.write_string(" --> ")
    output.write_string(source.name())
    output.write_string(":\{location.line() + 1}:\{location.column() + 1}")
    output.write_char('\n')
  }
  write_repeated(output, ' ', number_width)
  output.write_string(" |\n")
  for window_index, window in windows {
    if window_index > 0 {
      write_repeated(output, ' ', number_width)
      output.write_string(" :\n")
    }
    let mut line = window.first()
    while line <= window.last() {
      write_line_number(output, line, number_width)
      output.write_string(" | ")
      output.write_string(
        expand_tabs(source.line_text(line).unwrap(), config=display),
      )
      output.write_char('\n')
      render_annotation(output, source, labels, line, number_width, display)
      line += 1
    }
  }
  ignore(source_id)
}

///|
/// Renders a diagnostic with source context and aligned labels.
///
/// Invalid source IDs are omitted from source blocks but the diagnostic header,
/// notes, and help remain visible.
pub fn render(
  sources : SourceMap,
  diagnostic : Diagnostic,
  config? : RenderConfig = RenderConfig::default(),
) -> String {
  let output = StringBuilder::new()
  output.write_string(diagnostic.severity().name())
  if config.show_code {
    match diagnostic.code() {
      Some(code) => output.write_string("[\{code}]")
      None => ()
    }
  }
  output.write_string(": ")
  output.write_string(diagnostic.message())
  output.write_char('\n')
  let rendered_sources : Array[SourceId] = []
  for label in diagnostic.labels() {
    if rendered_sources.contains(label.source()) {
      continue
    }
    match sources.get(label.source()) {
      None => ()
      Some(source) => {
        let labels = source_labels(diagnostic, label.source())
        render_source_block(output, source, label.source(), labels, config)
        rendered_sources.push(label.source())
      }
    }
  }
  for note in diagnostic.notes() {
    output.write_string("  = note: ")
    output.write_string(note)
    output.write_char('\n')
  }
  match diagnostic.help() {
    Some(help) => {
      output.write_string("  = help: ")
      output.write_string(help)
      output.write_char('\n')
    }
    None => ()
  }
  output.to_string()
}

///|
/// Renders multiple reports with one blank line between them.
pub fn render_all(
  sources : SourceMap,
  diagnostics : Array[Diagnostic],
  config? : RenderConfig = RenderConfig::default(),
) -> String {
  let output = StringBuilder::new()
  for index, diagnostic in diagnostics {
    if index > 0 {
      output.write_char('\n')
    }
    output.write_string(render(sources, diagnostic, config~))
  }
  output.to_string()
}