///|
priv struct StrictDataLine {
  number : Int
  text : String
  physical : Int
}

///|
priv struct StrictState {
  diagnostics : Array[LasDiagnostic]
  entries : Array[LasEntry]
  data : Array[StrictDataLine]
}

///|
fn strict_issue(
  state : StrictState,
  clause : String,
  section : String,
  line : Int,
  field : String,
  message : String,
  kind? : String = "standard",
) -> Unit {
  if state.diagnostics.length() < 256 {
    state.diagnostics.push({ kind, clause, section, line, field, message, })
  }
}

///|
fn strict_scan(document : LasDocument) -> StrictState {
  let state : StrictState = { diagnostics: [], entries: [], data: [], }
  let seen : Array[String] = []
  let mut section = ""
  let lines = document.source.split("\n").to_array()
  if lines.length() > 1000000 {
    strict_issue(
      state,
      "budget",
      "",
      0,
      "lines",
      "source exceeds 1000000 physical lines",
      kind="resource",
    )
    return state
  }
  for i in 0.. 126 {
        strict_issue(
          state, "3", section, number, "ASCII", "only ASCII 32..126 and CRLF are permitted",
        )
        break
      }
    }
    let text = raw.trim().to_owned()
    if text.has_prefix("~") {
      if text.length() < 2 {
        strict_issue(
          state, "3", section, number, "section", "missing section identifier",
        )
        continue
      }
      let tag = text.iter().to_array()[1].to_string()
      if section == "A" {
        strict_issue(
          state, "3/5.8", "A", number, "section", "ASCII must be last",
        )
      }
      if seen.is_empty() && tag != "V" {
        strict_issue(
          state, "3/5.3", tag, number, "section", "Version must be first",
        )
      }
      if ["V", "W", "C", "P", "O", "A"].contains(tag) && seen.contains(tag) {
        strict_issue(
          state, "3", tag, number, "section", "reserved section may occur only once",
        )
      }
      section = tag
      seen.push(tag)
      continue
    }
    if section == "A" {
      if text == "" || text.has_prefix("#") {
        strict_issue(
          state, "5.1/5.8", "A", number, "record", "blank/comment lines are not allowed inside ASCII data",
        )
      } else {
        state.data.push({
          number,
          text,
          physical: raw.length() + (if terminated { 1 } else { 0 }),
        })
      }
    } else if text == "" || text.has_prefix("#") {
      continue
    } else if section == "" {
      strict_issue(
        state, "3", section, number, "section", "content precedes Version",
      )
    } else if ["V", "W", "C", "P"].contains(section) {
      state.entries.push({ section, line: number, header: parse_header(text), }) catch {
        WellError(message) =>
          strict_issue(state, "5.2", section, number, "header", message)
      }
    }
  }
  for tag in ["V", "W", "C", "A"] {
    if !seen.contains(tag) {
      strict_issue(state, "3", tag, 0, "section", "missing required section")
    }
  }
  state
}

///|
fn strict_headers(state : StrictState, section : String) -> Array[LasEntry] {
  state.entries.filter(e => e.section == section)
}

///|
fn strict_required(
  state : StrictState,
  section : String,
  name : String,
  clause : String,
) -> LasEntry? {
  let entries = state.entries.filter(e => {
    e.section == section && e.header.name == name
  })
  if entries.is_empty() {
    strict_issue(state, clause, section, 0, name, "missing required header")
    None
  } else {
    if entries.length() > 1 {
      strict_issue(
        state,
        clause,
        section,
        entries[1].line,
        name,
        "required header is ambiguous (duplicate)",
      )
    }
    Some(entries[0])
  }
}

///|
fn strict_number(state : StrictState, entry : LasEntry) -> LasDecimal? {
  if !decimal_grammar(entry.header.value) {
    strict_issue(
      state,
      "3/5.4",
      entry.section,
      entry.line,
      entry.header.name,
      "expected decimal without exponent",
    )
    None
  } else if entry.header.value.length() > 1024 {
    strict_issue(
      state,
      "budget",
      entry.section,
      entry.line,
      entry.header.name,
      "numeric token exceeds 1024 code units",
      kind="resource",
    )
    None
  } else {
    Some(exact_decimal(entry.header.value))
  }
}

///|
fn strict_well(
  state : StrictState,
  curves : Array[LasEntry],
) -> (LasDecimal?, LasDecimal?, LasDecimal?, LasDecimal?) {
  let numbers : Array[LasDecimal?] = []
  for name in ["STRT", "STOP", "STEP", "NULL"] {
    let entry = strict_required(state, "W", name, "5.4")
    match entry {
      None => numbers.push(None)
      Some(e) => {
        numbers.push(strict_number(state, e))
        if name == "NULL" {
          if e.header.unit != "" {
            strict_issue(state, "5.4", "W", e.line, name, "NULL has no unit")
          }
        } else if !curves.is_empty() && e.header.unit != curves[0].header.unit {
          strict_issue(
            state,
            "5.4",
            "W",
            e.line,
            name,
            "unit must match index channel exactly",
          )
        }
      }
    }
  }
  for name in ["COMP", "WELL", "FLD", "LOC", "SRVC", "DATE"] {
    ignore(strict_required(state, "W", name, "5.4"))
  }
  let well = strict_headers(state, "W")
  if well.any(e => e.header.name == "PROV") {
    ignore(strict_required(state, "W", "PROV", "5.4"))
  } else {
    for name in ["CNTY", "STAT", "CTRY"] {
      ignore(strict_required(state, "W", name, "5.4"))
    }
  }
  for name in ["UWI", "API"] {
    if well.any(e => e.header.name == name) {
      ignore(strict_required(state, "W", name, "5.4"))
    }
  }
  if !well.any(e => e.header.name == "UWI") &&
    !well.any(e => e.header.name == "API") {
    strict_issue(
      state, "5.4", "W", 0, "UWI/API", "one unique well identifier header is required (empty value is allowed)",
    )
  }
  (numbers[0], numbers[1], numbers[2], numbers[3])
}

///|
fn strict_data(
  state : StrictState,
  curves : Array[LasEntry],
  wrapped : Bool,
  start : LasDecimal?,
  stop : LasDecimal?,
  step : LasDecimal?,
  null : LasDecimal?,
) -> Unit {
  if curves.is_empty() {
    return
  }
  let mut column = 0
  let mut first : LasDecimal? = None
  let mut previous : LasDecimal? = None
  let mut direction = 0
  let mut rows = 0
  for line in state.data {
    let tokens = words(line.text)
    if wrapped {
      if line.physical > 80 {
        strict_issue(
          state,
          "5.3/5.8",
          "A",
          line.number,
          "line",
          "wrapped data line exceeds 80 characters including CRLF",
        )
      }
      if column == 0 && tokens.length() != 1 {
        strict_issue(
          state,
          "5.8",
          "A",
          line.number,
          "index",
          "wrapped index must be alone on its line",
        )
      }
      if column + tokens.length() > curves.length() {
        strict_issue(
          state,
          "5.8",
          "A",
          line.number,
          "width",
          "physical line crosses a wrapped record boundary",
        )
      }
    } else if tokens.length() != curves.length() {
      strict_issue(
        state,
        "5.5/5.8",
        "A",
        line.number,
        "width",
        "data width must equal curve count",
      )
    }
    for token in tokens {
      let valid = decimal_grammar(token)
      let within_budget = token.length() <= 1024
      if !valid {
        strict_issue(
          state,
          "3/5.8",
          "A",
          line.number,
          curves[column].header.name,
          "sample must be decimal without exponent",
        )
      } else if !within_budget {
        strict_issue(
          state,
          "budget",
          "A",
          line.number,
          curves[column].header.name,
          "numeric token exceeds 1024 code units",
          kind="resource",
        )
      }
      if column == 0 && valid && within_budget {
        let index = exact_decimal(token)
        if null is Some(n) && decimal_compare(index, n) == 0 {
          strict_issue(
            state,
            "5.5",
            "A",
            line.number,
            "index",
            "index cannot be NULL",
          )
        }
        if first is None {
          first = Some(index)
        }
        if previous is Some(p) {
          let sign = decimal_compare(index, p)
          if sign == 0 || (direction != 0 && sign != direction) {
            strict_issue(
              state,
              "3/5.4",
              "A",
              line.number,
              "index",
              "index must be strictly monotonic",
            )
          }
          if curves[0].header.name == "TIME" && sign <= 0 {
            strict_issue(
              state,
              "5.4",
              "A",
              line.number,
              "TIME",
              "time values must increase",
            )
          }
          direction = sign
          if step is Some(s) &&
            !s.coefficient.is_zero() &&
            decimal_compare(decimal_difference(index, p), s) != 0 {
            strict_issue(
              state,
              "5.4",
              "A",
              line.number,
              "STEP",
              "exact decimal interval differs from STEP; rounding is not allowed",
            )
          }
        }
        previous = Some(index)
        rows += 1
      }
      column = (column + 1) % curves.length()
    }
    if !wrapped {
      column = 0
    }
  }
  if column != 0 {
    strict_issue(state, "5.8", "A", 0, "record", "incomplete wrapped record")
  }
  if rows == 0 {
    strict_issue(
      state, "5.4/5.8", "A", 0, "record", "at least one indexed data record is required",
    )
  }
  if start is Some(a) && first is Some(b) && decimal_compare(a, b) != 0 {
    strict_issue(state, "5.4", "W", 0, "STRT", "must exactly equal first index")
  }
  if stop is Some(a) && previous is Some(b) && decimal_compare(a, b) != 0 {
    strict_issue(state, "5.4", "W", 0, "STOP", "must exactly equal last index")
  }
  if step is Some(s) && !s.coefficient.is_zero() {
    if start is Some(a) && !decimal_multiple(a, s) {
      strict_issue(
        state, "1/5.4", "W", 0, "STRT/STEP", "start divided by step must be an integer",
      )
    }
    if stop is Some(a) && !decimal_multiple(a, s) {
      strict_issue(
        state, "1/5.4", "W", 0, "STOP/STEP", "stop divided by step must be an integer",
      )
    }
  }
}

///|
/// CWLS January 2014 structural and exact-decimal validator, independent of Log.
/// An empty diagnostic array means this baseline passes within stated budgets,
/// not latest-edition certification or correctness of geological content.
pub fn check_las2(document : LasDocument) -> Array[LasDiagnostic] {
  let state = strict_scan(document)
  if state.diagnostics.any(d => d.kind == "resource") {
    return state.diagnostics
  }
  if state.entries.length() > 65536 || state.data.length() > 1000000 {
    strict_issue(
      state,
      "budget",
      "",
      0,
      "entries",
      "metadata/data record budget exceeded",
      kind="resource",
    )
    return state.diagnostics
  }
  for name in ["VERS", "WRAP"] {
    if strict_required(state, "V", name, "5.3") is Some(e) {
      if e.header.unit != "" {
        strict_issue(
          state,
          "5.3",
          "V",
          e.line,
          name,
          "required version field has no unit",
        )
      }
      if name == "VERS" {
        if !decimal_grammar(e.header.value) || e.header.value.length() > 1024 {
          strict_issue(state, "5.3", "V", e.line, name, "expected version 2.0")
        } else if decimal_compare(
            exact_decimal(e.header.value),
            exact_decimal("2"),
          ) !=
          0 {
          strict_issue(
            state,
            "5.3",
            "V",
            e.line,
            name,
            "only version 2.0 passes this baseline",
          )
        }
      } else if e.header.value != "YES" && e.header.value != "NO" {
        strict_issue(state, "5.3", "V", e.line, name, "WRAP must be YES or NO")
      }
    }
  }
  let curves = strict_headers(state, "C")
  if curves.is_empty() {
    strict_issue(
      state, "5.5", "C", 0, "index", "at least one index curve is required",
    )
  } else {
    let index = curves[0]
    if !["DEPT", "DEPTH", "TIME"].contains(index.header.name) {
      strict_issue(
        state,
        "5.5",
        "C",
        index.line,
        index.header.name,
        "index mnemonic must be DEPT, DEPTH or TIME",
      )
    }
    if index.header.name != "TIME" &&
      !["M", "F", "FT"].contains(index.header.unit) {
      strict_issue(
        state,
        "5.4",
        "C",
        index.line,
        "unit",
        "depth unit must be M, F or FT",
      )
    }
    if index.header.name == "TIME" && index.header.unit == "" {
      strict_issue(
        state,
        "5.4",
        "C",
        index.line,
        "unit",
        "time index must declare a numeric time unit",
      )
    }
  }
  if curves.length() > 4096 {
    strict_issue(
      state,
      "budget",
      "C",
      0,
      "curves",
      "document curve budget is 4096",
      kind="resource",
    )
    return state.diagnostics
  }
  let (start, stop, step, null) = strict_well(state, curves)
  let wrapped = state.entries.any(e => {
    e.section == "V" && e.header.name == "WRAP" && e.header.value == "YES"
  })
  strict_data(state, curves, wrapped, start, stop, step, null)
  state.diagnostics
}

///|
/// Refuses absent well metadata, off-grid decimal coordinates and other standard
/// violations. Never fabricates company/location/identifier or rounds observations.
pub fn write_las_strict(
  log : Log,
  wrapped? : Bool = false,
) -> String raise WellError {
  let text = write_las(log, wrapped~)
  let document = read_las_document(text)
  let errors = check_las2(document)
  if !errors.is_empty() {
    fail(conformance_error(errors[0]))
  }
  text
}