///|
/// A TAP directive attached to a test point.
pub(all) enum TapDirective {
  NoDirective
  Skip(String)
  Todo(String)
} derive(Debug, Eq)

///|
/// One parsed TAP test point.
pub(all) struct TapPoint {
  line : Int
  number : Int
  ok : Bool
  name : String
  directive : TapDirective
  raw : String
} derive(Debug, Eq)

///|
/// A diagnostic comment line.
pub(all) struct TapDiagnostic {
  line : Int
  text : String
} derive(Debug, Eq)

///|
/// A YAMLish TAP diagnostic block.
pub(all) struct TapYamlBlock {
  start_line : Int
  end_line : Int
  lines : Array[String]
} derive(Debug, Eq)

///|
/// A parser or validation issue.
pub(all) struct TapIssue {
  line : Int
  code : String
  message : String
} derive(Debug, Eq)

///|
/// Parsed TAP stream with non-fatal parser issues.
pub(all) struct TapDocument {
  has_version : Bool
  version : String
  has_plan : Bool
  plan_start : Int
  plan_end : Int
  points : Array[TapPoint]
  diagnostics : Array[TapDiagnostic]
  yaml_blocks : Array[TapYamlBlock]
  bailed_out : Bool
  bailout_reason : String
  parse_issues : Array[TapIssue]
} derive(Debug, Eq)

///|
/// Aggregate counts for one TAP stream.
pub(all) struct TapSummary {
  planned : Int
  total : Int
  passed : Int
  failed : Int
  skipped : Int
  todo : Int
  diagnostics : Int
  yaml_blocks : Int
} derive(Debug, Eq)

///|
/// A validation report ready for CI gates and release notes.
pub(all) struct TapReport {
  document : TapDocument
  summary : TapSummary
  issues : Array[TapIssue]
  ok : Bool
} derive(Debug, Eq)

///|
/// Parse a TAP13-ish stream. The parser is permissive and records unknown lines
/// as issues instead of aborting.
pub fn parse_tap(input : String) -> TapDocument {
  let points : Array[TapPoint] = []
  let diagnostics : Array[TapDiagnostic] = []
  let yaml_blocks : Array[TapYamlBlock] = []
  let parse_issues : Array[TapIssue] = []
  let mut has_version = false
  let mut version = ""
  let mut has_plan = false
  let mut plan_start = 0
  let mut plan_end = 0
  let mut bailed_out = false
  let mut bailout_reason = ""
  let mut line_no = 0
  let mut in_yaml = false
  let mut yaml_start = 0
  let yaml_lines : Array[String] = []
  for raw in input.split("\n") {
    line_no += 1
    let line = raw.trim_end(chars="\r").to_owned()
    let trimmed = line.trim_start().to_owned()
    if in_yaml {
      yaml_lines.push(line)
      if trimmed == "..." {
        yaml_blocks.push(TapYamlBlock::{
          start_line: yaml_start,
          end_line: line_no,
          lines: yaml_lines.copy(),
        })
        yaml_lines.clear()
        in_yaml = false
      }
      continue
    }
    if trimmed.length() == 0 {
      continue
    } else if trimmed == "TAP version 13" {
      has_version = true
      version = "13"
    } else if trimmed == "---" {
      in_yaml = true
      yaml_start = line_no
      yaml_lines.clear()
      yaml_lines.push(line)
    } else if trimmed.has_prefix("Bail out!") {
      bailed_out = true
      bailout_reason = trimmed[9:].trim_start().to_owned()
    } else if trimmed.has_prefix("#") {
      diagnostics.push(TapDiagnostic::{
        line: line_no,
        text: trimmed[1:].trim_start().to_owned(),
      })
    } else if is_plan_line(trimmed) {
      match parse_plan_line(trimmed) {
        Some((start, end)) => {
          has_plan = true
          plan_start = start
          plan_end = end
        }
        None =>
          parse_issues.push(issue(line_no, "invalid-plan", "invalid TAP plan"))
      }
    } else if is_point_line(trimmed) {
      match parse_point_line(trimmed, line_no) {
        Some(point) => points.push(point)
        None =>
          parse_issues.push(
            issue(line_no, "invalid-point", "invalid test point"),
          )
      }
    } else {
      parse_issues.push(
        issue(line_no, "unknown-line", "line is not a TAP13 construct"),
      )
    }
  }
  if in_yaml {
    parse_issues.push(
      issue(
        yaml_start, "unterminated-yaml", "YAMLish diagnostic block was not closed",
      ),
    )
    yaml_blocks.push(TapYamlBlock::{
      start_line: yaml_start,
      end_line: line_no,
      lines: yaml_lines.copy(),
    })
  }
  TapDocument::{
    has_version,
    version,
    has_plan,
    plan_start,
    plan_end,
    points,
    diagnostics,
    yaml_blocks,
    bailed_out,
    bailout_reason,
    parse_issues,
  }
}

///|
/// Build aggregate counters for a parsed TAP document.
pub fn summarize(doc : TapDocument) -> TapSummary {
  let mut passed = 0
  let mut failed = 0
  let mut skipped = 0
  let mut todo = 0
  for point in doc.points {
    match point.directive {
      Skip(_) => skipped += 1
      Todo(_) => todo += 1
      NoDirective => ()
    }
    if point.ok {
      passed += 1
    } else {
      failed += 1
    }
  }
  TapSummary::{
    planned: if doc.has_plan {
      doc.plan_end - doc.plan_start + 1
    } else {
      0
    },
    total: doc.points.length(),
    passed,
    failed,
    skipped,
    todo,
    diagnostics: doc.diagnostics.length(),
    yaml_blocks: doc.yaml_blocks.length(),
  }
}

///|
/// Validate a parsed TAP document for release or CI gating.
pub fn validate(doc : TapDocument) -> TapReport {
  let issues = doc.parse_issues.copy()
  let summary = summarize(doc)
  if !doc.has_version {
    issues.push(issue(0, "missing-version", "TAP version line is missing"))
  } else if doc.version != "13" {
    issues.push(
      issue(1, "unsupported-version", "only TAP version 13 is supported"),
    )
  }
  if !doc.has_plan {
    issues.push(issue(0, "missing-plan", "TAP plan is missing"))
  } else if doc.plan_start != 1 {
    issues.push(issue(0, "plan-start", "plan should start at 1"))
  } else if summary.planned != summary.total {
    issues.push(
      issue(
        0, "plan-count", "number of test points does not match the TAP plan",
      ),
    )
  }
  if doc.bailed_out {
    issues.push(issue(0, "bailout", "stream contains Bail out!"))
  }
  for point in doc.points {
    if !point.ok {
      match point.directive {
        Todo(_) => ()
        _ =>
          issues.push(
            issue(
              point.line,
              "failed-point",
              "test point failed: \{point.name}",
            ),
          )
      }
    }
  }
  let duplicate_numbers = find_duplicate_numbers(doc.points)
  for n in duplicate_numbers {
    issues.push(
      issue(0, "duplicate-number", "duplicate test point number \{n}"),
    )
  }
  if !is_monotonic(doc.points) {
    issues.push(
      issue(
        0, "non-monotonic", "test point numbers are not strictly increasing",
      ),
    )
  }
  TapReport::{ document: doc, summary, issues, ok: issues.is_empty() }
}

///|
/// Parse and validate a TAP stream in one call.
pub fn parse_and_report(input : String) -> TapReport {
  validate(parse_tap(input))
}

///|
/// Render a compact Markdown report for CI logs or release artifacts.
pub fn to_markdown(report : TapReport) -> String {
  let sb = StringBuilder()
  sb.write_string("# TAPTrail Report\n\n")
  sb.write_string("- status: ")
  sb.write_string(if report.ok { "ok" } else { "failed" })
  sb.write_string("\n")
  sb.write_string("- planned: \{report.summary.planned}\n")
  sb.write_string("- total: \{report.summary.total}\n")
  sb.write_string("- passed: \{report.summary.passed}\n")
  sb.write_string("- failed: \{report.summary.failed}\n")
  sb.write_string("- skipped: \{report.summary.skipped}\n")
  sb.write_string("- todo: \{report.summary.todo}\n")
  sb.write_string("- diagnostics: \{report.summary.diagnostics}\n")
  sb.write_string("- yaml_blocks: \{report.summary.yaml_blocks}\n")
  if report.issues.is_empty() {
    sb.write_string("\nNo validation issues.\n")
  } else {
    sb.write_string("\n## Issues\n\n")
    for item in report.issues {
      sb.write_string("- ")
      sb.write_string(item.code)
      if item.line > 0 {
        sb.write_string(" at line \{item.line}")
      }
      sb.write_string(": ")
      sb.write_string(item.message)
      sb.write_string("\n")
    }
  }
  sb.to_string()
}

///|
/// Render a small JSON object without requiring a JSON dependency.
pub fn to_compact_json(report : TapReport) -> String {
  let sb = StringBuilder()
  sb.write_string("{")
  sb.write_string("\"ok\":")
  sb.write_string(if report.ok { "true" } else { "false" })
  sb.write_string(",\"planned\":\{report.summary.planned}")
  sb.write_string(",\"total\":\{report.summary.total}")
  sb.write_string(",\"passed\":\{report.summary.passed}")
  sb.write_string(",\"failed\":\{report.summary.failed}")
  sb.write_string(",\"skipped\":\{report.summary.skipped}")
  sb.write_string(",\"todo\":\{report.summary.todo}")
  sb.write_string(",\"issues\":[")
  for item in report.issues; first = true {
    if first {
      ()
    } else {
      sb.write_string(",")
    }
    sb.write_string("{\"code\":\"")
    sb.write_string(json_escape(item.code))
    sb.write_string("\",\"line\":\{item.line},\"message\":\"")
    sb.write_string(json_escape(item.message))
    sb.write_string("\"}")
    continue false
  }
  sb.write_string("]}")
  sb.to_string()
}

///|
/// Render a JUnit-style XML test suite for CI systems that can ingest XML.
pub fn to_junit_xml(
  report : TapReport,
  suite_name? : String = "taptrail",
) -> String {
  let failures = blocking_failure_count(report.document)
  let sb = StringBuilder()
  sb.write_string("\n")
  sb.write_string("\n",
  )
  for point in report.document.points {
    sb.write_string("  \n")
    } else {
      sb.write_string(">\n")
      match point.directive {
        Skip(reason) => {
          sb.write_string("    \n")
        }
        Todo(reason) => {
          sb.write_string("    \n")
        }
        NoDirective =>
          if !point.ok {
            sb.write_string("    ")
            sb.write_string(xml_escape(point.raw))
            sb.write_string("\n")
          }
      }
      sb.write_string("  \n")
    }
  }
  if report.issues.length() > 0 {
    sb.write_string("  ")
    for item in report.issues {
      sb.write_string(xml_escape("[\{item.code}] \{item.message}\n"))
    }
    sb.write_string("\n")
  }
  sb.write_string("\n")
  sb.to_string()
}

///|
/// Return true when the report has no validation issues.
pub fn report_passes(input : String) -> Bool {
  parse_and_report(input).ok
}

///|
fn is_point_line(line : String) -> Bool {
  line == "ok" ||
  line.has_prefix("ok ") ||
  line == "not ok" ||
  line.has_prefix("not ok ")
}

///|
fn is_plan_line(line : String) -> Bool {
  let trimmed = line.trim_start()
  let mut i = 0
  let mut saw_digit = false
  while i < trimmed.length() {
    let c = trimmed.unsafe_get(i).to_int()
    if c >= 48 && c <= 57 {
      saw_digit = true
      i += 1
    } else {
      break
    }
  }
  saw_digit && trimmed[i:].has_prefix("..")
}

///|
fn parse_plan_line(line : String) -> (Int, Int)? {
  let trimmed = line.trim_start()
  match parse_uint_prefix(trimmed) {
    Some((start, rest1)) => {
      let rest = rest1.trim_start()
      guard rest.has_prefix("..") else { return None }
      let after_dots = rest[2:].trim_start()
      match parse_uint_prefix(after_dots) {
        Some((end, _)) => Some((start, end))
        None => None
      }
    }
    None => None
  }
}

///|
fn parse_point_line(line : String, line_no : Int) -> TapPoint? {
  let ok = line == "ok" || line.has_prefix("ok ")
  let rest0 = if ok { line[2:] } else { line[6:] }
  let rest = rest0.trim_start()
  let (number, after_number) = match parse_uint_prefix(rest) {
    Some(pair) => pair
    None => (0, rest)
  }
  let after = after_number.trim_start()
  let body = match after.strip_prefix("-") {
    Some(x) => x.trim_start()
    None => after
  }
  let (name, directive) = split_directive(body)
  Some(TapPoint::{ line: line_no, number, ok, name, directive, raw: line })
}

///|
fn split_directive(body : StringView) -> (String, TapDirective) {
  match body.find("#") {
    Some(index) => {
      let name = body[:index].trim_end().to_owned()
      let directive_text = body[index + 1:].trim_start()
      if directive_text.has_prefix("SKIP") {
        (name, Skip(directive_text[4:].trim_start().to_owned()))
      } else if directive_text.has_prefix("TODO") {
        (name, Todo(directive_text[4:].trim_start().to_owned()))
      } else {
        (body.to_owned(), NoDirective)
      }
    }
    None => (body.trim_end().to_owned(), NoDirective)
  }
}

///|
fn parse_uint_prefix(view : StringView) -> (Int, StringView)? {
  let mut i = 0
  let mut value = 0
  while i < view.length() {
    let c = view.unsafe_get(i).to_int()
    if c >= 48 && c <= 57 {
      value = value * 10 + c - 48
      i += 1
    } else {
      break
    }
  }
  if i == 0 {
    None
  } else {
    Some((value, view[i:]))
  }
}

///|
fn find_duplicate_numbers(points : Array[TapPoint]) -> Array[Int] {
  let duplicates : Array[Int] = []
  for i in 0.. Bool {
  let mut last = 0
  for point in points {
    if point.number == 0 {
      continue
    }
    if point.number <= last {
      return false
    }
    last = point.number
  }
  true
}

///|
fn blocking_failure_count(doc : TapDocument) -> Int {
  let mut count = 0
  for point in doc.points {
    if !point.ok {
      match point.directive {
        Todo(_) => ()
        _ => count += 1
      }
    }
  }
  count
}

///|
fn point_name(point : TapPoint) -> String {
  if point.name.length() > 0 {
    point.name
  } else if point.number > 0 {
    "test \{point.number}"
  } else {
    "unnumbered at line \{point.line}"
  }
}

///|
fn issue(line : Int, code : String, message : String) -> TapIssue {
  TapIssue::{ line, code, message }
}

///|
fn json_escape(text : String) -> String {
  let sb = StringBuilder()
  for ch in text {
    match ch {
      '"' => sb.write_string("\\\"")
      '\\' => sb.write_string("\\\\")
      '\n' => sb.write_string("\\n")
      '\r' => sb.write_string("\\r")
      '\t' => sb.write_string("\\t")
      _ => sb.write_char(ch)
    }
  }
  sb.to_string()
}

///|
fn xml_escape(text : String) -> String {
  let sb = StringBuilder()
  for ch in text {
    match ch {
      '&' => sb.write_string("&")
      '<' => sb.write_string("<")
      '>' => sb.write_string(">")
      '"' => sb.write_string(""")
      '\'' => sb.write_string("'")
      _ => sb.write_char(ch)
    }
  }
  sb.to_string()
}