///|
priv struct CheckerState {
  mut format_seen_count : Int
  mut unit_seen_count : Int
  mut first_operation_seen : Bool
  apertures : Map[Int, ApertureDefinition]
  aperture_macros : Map[String, Bool]
  mut current_aperture : Int?
  mut plot_mode : PlotMode?
  mut g75_seen : Bool
  mut current_x_defined : Bool
  mut current_y_defined : Bool
  mut region_open : Bool
  mut end_seen : Bool
  mut end_count : Int
  file_attributes : Map[String, String]
  mut file_function : String?
  mut file_polarity : String?
  mut generation_software : String?
  mut unit : GerberUnit?
  mut coordinate_format : CoordinateFormat?
  mut statistics : GerberStatistics
  issues : Array[Issue]
  mut region_geometry_warned : Bool
  mut arc_geometry_warned : Bool
}

///|
fn new_checker_state() -> CheckerState {
  {
    format_seen_count: 0,
    unit_seen_count: 0,
    first_operation_seen: false,
    apertures: Map([]),
    aperture_macros: Map([]),
    current_aperture: None,
    plot_mode: None,
    g75_seen: false,
    current_x_defined: false,
    current_y_defined: false,
    region_open: false,
    end_seen: false,
    end_count: 0,
    file_attributes: Map([]),
    file_function: None,
    file_polarity: None,
    generation_software: None,
    unit: None,
    coordinate_format: None,
    statistics: empty_statistics(),
    issues: [],
    region_geometry_warned: false,
    arc_geometry_warned: false,
  }
}

///|
fn add_error(
  state : CheckerState,
  code : String,
  message : String,
  line : Int?,
) -> Unit {
  state.issues.push({ code, severity: Error, message, line })
}

///|
fn add_warning(
  state : CheckerState,
  code : String,
  message : String,
  line : Int?,
) -> Unit {
  state.issues.push({ code, severity: Warning, message, line })
}

///|
fn check_commands(
  commands : Array[LocatedCommand],
  extra_issues : Array[Issue],
  issue_command_indices : Array[Int],
) -> GerberReport {
  let state = new_checker_state()
  let mut extra_index = 0
  let mut command_index = 0
  while command_index < commands.length() {
    let cmd = commands[command_index]
    while extra_index < extra_issues.length() &&
          issue_command_indices[extra_index] == command_index {
      state.issues.push(extra_issues[extra_index])
      extra_index = extra_index + 1
    }
    if state.end_seen {
      // Any command after M02
      add_error(state, "G131", "Data found after M02.", Some(cmd.line))
      // continue checking lightly
    }
    check_one(state, cmd)
    command_index = command_index + 1
  }
  while extra_index < extra_issues.length() {
    state.issues.push(extra_issues[extra_index])
    extra_index = extra_index + 1
  }
  finalize_report(state)
}

///|
fn check_one(state : CheckerState, located : LocatedCommand) -> Unit {
  let line = located.line
  match located.command {
    Comment(_) => {
      check_region_disallowed(state, line)
      state.statistics = {
        ..state.statistics,
        comments: state.statistics.comments + 1,
      }
    }
    Format(fmt) => check_format(state, fmt, line)
    Unit(unit) => check_unit(state, unit, line)
    ApertureMacro(name, _) => check_am(state, name, line)
    ApertureDefinition(def) => check_ad(state, def, line)
    SelectAperture(code) => check_select(state, code, line)
    SetPlotMode(mode) =>
      // G01/G02/G03 allowed inside region
      state.plot_mode = Some(mode)
    MultiQuadrant => {
      check_region_disallowed(state, line)
      state.g75_seen = true
    }
    Move(fields) => check_move(state, fields, line)
    Draw(fields) => check_draw(state, fields, line)
    Flash(fields) => check_flash(state, fields, line)
    BeginRegion => check_begin_region(state, line)
    EndRegion => check_end_region(state, line)
    SetPolarity(_) => check_region_disallowed(state, line)
    Attribute(attr) => check_attribute(state, attr, line)
    DeleteAttribute(_) => {
      check_region_disallowed(state, line)
      state.statistics = {
        ..state.statistics,
        attributes: state.statistics.attributes + 1,
      }
    }
    EndFile => check_end_file(state, line)
    UnknownWord(raw) => check_unknown(state, raw, line, false)
    UnknownExtended(raw) => check_unknown(state, raw, line, true)
    Malformed(_) =>
      // Issues already emitted by parser when applicable
      check_region_disallowed_if_open(state, line)
  }
}

///|
fn check_region_disallowed(state : CheckerState, line : Int) -> Unit {
  if state.region_open {
    add_error(
      state,
      "G223",
      "Command is not allowed inside a region.",
      Some(line),
    )
  }
}

///|
fn check_region_disallowed_if_open(state : CheckerState, line : Int) -> Unit {
  if state.region_open {
    add_error(
      state,
      "G223",
      "Command is not allowed inside a region.",
      Some(line),
    )
  }
}

///|
fn check_format(
  state : CheckerState,
  fmt : CoordinateFormat,
  line : Int,
) -> Unit {
  check_region_disallowed(state, line)
  state.format_seen_count = state.format_seen_count + 1
  if state.format_seen_count > 1 {
    add_error(state, "G121", "Duplicate format specification.", Some(line))
  } else {
    state.coordinate_format = Some(fmt)
  }
  if state.first_operation_seen {
    add_error(
      state,
      "G122",
      "Format specification appears after first operation.",
      Some(line),
    )
  }
}

///|
fn check_unit(state : CheckerState, unit : GerberUnit, line : Int) -> Unit {
  check_region_disallowed(state, line)
  state.unit_seen_count = state.unit_seen_count + 1
  if state.unit_seen_count > 1 {
    add_error(state, "G111", "Duplicate unit statement.", Some(line))
  } else {
    state.unit = Some(unit)
  }
  if state.first_operation_seen {
    add_error(
      state,
      "G112",
      "Unit statement appears after first operation.",
      Some(line),
    )
  }
}

///|
fn check_am(state : CheckerState, name : String, line : Int) -> Unit {
  check_region_disallowed(state, line)
  state.aperture_macros.set(name, true)
  state.statistics = {
    ..state.statistics,
    aperture_macros: state.statistics.aperture_macros + 1,
  }
  add_warning(
    state,
    "G303",
    "Aperture macro geometry was not analyzed.",
    Some(line),
  )
}

///|
fn check_ad(state : CheckerState, def : ApertureDefinition, line : Int) -> Unit {
  check_region_disallowed(state, line)
  state.statistics = {
    ..state.statistics,
    apertures: state.statistics.apertures + 1,
  }
  // Standard templates C,R,O,P don't need macro; others need macro defined
  let std = def.template == "C" ||
    def.template == "R" ||
    def.template == "O" ||
    def.template == "P"
  if !std {
    match state.aperture_macros.get(def.template) {
      None =>
        add_error(
          state,
          "G205",
          "Undefined aperture macro " + def.template + ".",
          Some(line),
        )
      Some(_) => ()
    }
  }
  match state.apertures.get(def.code) {
    Some(_) =>
      add_error(
        state,
        "G202",
        "Aperture D" + def.code.to_string() + " was defined more than once.",
        Some(line),
      )
    None => state.apertures.set(def.code, def)
  }
}

///|
fn check_select(state : CheckerState, code : Int, line : Int) -> Unit {
  check_region_disallowed(state, line)
  match state.apertures.get(code) {
    None =>
      add_error(
        state,
        "G201",
        "Undefined aperture D" + code.to_string() + ".",
        Some(line),
      )
    Some(_) => state.current_aperture = Some(code)
  }
}

///|
fn mark_first_operation(state : CheckerState) -> Unit {
  state.first_operation_seen = true
}

///|
fn validate_coordinate_precision(
  state : CheckerState,
  fields : CoordinateFields,
  line : Int,
) -> Unit {
  match state.coordinate_format {
    None => ()
    Some(fmt) => {
      let max_digits = fmt.integer_digits + fmt.decimal_digits
      fn check_one_coord(v : String?) -> Unit {
        match v {
          None => ()
          Some(s) =>
            if digit_count_of_coordinate(s) > max_digits {
              add_error(
                state,
                "G216",
                "Coordinate exceeds declared FS precision.",
                Some(line),
              )
            }
        }
      }
      check_one_coord(fields.x)
      check_one_coord(fields.y)
      check_one_coord(fields.i)
      check_one_coord(fields.j)
    }
  }
}

///|
fn check_move(
  state : CheckerState,
  fields : CoordinateFields,
  line : Int,
) -> Unit {
  // D02 allowed in region
  mark_first_operation(state)
  validate_coordinate_precision(state, fields, line)
  // Resolve point: need complete X/Y via new or old
  let x_ok = match fields.x {
    Some(_) => true
    None => state.current_x_defined
  }
  let y_ok = match fields.y {
    Some(_) => true
    None => state.current_y_defined
  }
  if !(x_ok && y_ok) {
    add_error(state, "G217", "Current point cannot be resolved.", Some(line))
  }
  match fields.x {
    Some(_) => state.current_x_defined = true
    None => ()
  }
  match fields.y {
    Some(_) => state.current_y_defined = true
    None => ()
  }
  state.statistics = { ..state.statistics, moves: state.statistics.moves + 1 }
}

///|
fn check_flash(
  state : CheckerState,
  fields : CoordinateFields,
  line : Int,
) -> Unit {
  mark_first_operation(state)
  if state.region_open {
    add_error(
      state,
      "G223",
      "Command is not allowed inside a region.",
      Some(line),
    )
  }
  validate_coordinate_precision(state, fields, line)
  match state.current_aperture {
    None =>
      add_error(state, "G206", "No current aperture selected.", Some(line))
    Some(_) => ()
  }
  let x_ok = match fields.x {
    Some(_) => true
    None => state.current_x_defined
  }
  let y_ok = match fields.y {
    Some(_) => true
    None => state.current_y_defined
  }
  if !(x_ok && y_ok) {
    add_error(state, "G217", "Current point cannot be resolved.", Some(line))
  }
  match fields.x {
    Some(_) => state.current_x_defined = true
    None => ()
  }
  match fields.y {
    Some(_) => state.current_y_defined = true
    None => ()
  }
  state.statistics = {
    ..state.statistics,
    flashes: state.statistics.flashes + 1,
  }
}

///|
fn check_draw(
  state : CheckerState,
  fields : CoordinateFields,
  line : Int,
) -> Unit {
  mark_first_operation(state)
  // Region: D01 allowed, no aperture required
  if !state.region_open {
    match state.current_aperture {
      None =>
        add_error(state, "G206", "No current aperture selected.", Some(line))
      Some(_) => ()
    }
  }
  validate_coordinate_precision(state, fields, line)
  if !(state.current_x_defined && state.current_y_defined) {
    add_error(
      state,
      "G211",
      "Draw operation has no valid current point.",
      Some(line),
    )
  }
  match state.plot_mode {
    None =>
      add_error(
        state,
        "G212",
        "Draw operation has no active plot mode.",
        Some(line),
      )
    Some(Linear) =>
      if fields.i is Some(_) || fields.j is Some(_) {
        add_error(
          state,
          "G214",
          "I/J offset is invalid in linear mode.",
          Some(line),
        )
      }
    Some(Clockwise) | Some(CounterClockwise) => {
      if !state.g75_seen {
        add_error(state, "G213", "Circular draw before G75.", Some(line))
      }
      if !(fields.i is Some(_) && fields.j is Some(_)) {
        add_error(
          state,
          "G215",
          "Circular draw requires both I and J offsets.",
          Some(line),
        )
      }
      if !state.arc_geometry_warned {
        add_warning(state, "G306", "Arc geometry was not verified.", Some(line))
        state.arc_geometry_warned = true
      }
      state.statistics = {
        ..state.statistics,
        arc_draws: state.statistics.arc_draws + 1,
      }
    }
  }
  // Update point after draw using end coords
  match fields.x {
    Some(_) => state.current_x_defined = true
    None => ()
  }
  match fields.y {
    Some(_) => state.current_y_defined = true
    None => ()
  }
  state.statistics = { ..state.statistics, draws: state.statistics.draws + 1 }
}

///|
fn check_begin_region(state : CheckerState, line : Int) -> Unit {
  if state.region_open {
    add_error(state, "G220", "Nested region is not allowed.", Some(line))
  } else {
    state.region_open = true
    state.statistics = {
      ..state.statistics,
      regions: state.statistics.regions + 1,
    }
    if !state.region_geometry_warned {
      add_warning(
        state,
        "G305",
        "Region geometry was not verified.",
        Some(line),
      )
      state.region_geometry_warned = true
    }
  }
}

///|
fn check_end_region(state : CheckerState, line : Int) -> Unit {
  if !state.region_open {
    add_error(state, "G221", "Region end without active region.", Some(line))
  } else {
    state.region_open = false
  }
}

///|
fn check_attribute(
  state : CheckerState,
  attr : GerberAttribute,
  line : Int,
) -> Unit {
  check_region_disallowed(state, line)
  state.statistics = {
    ..state.statistics,
    attributes: state.statistics.attributes + 1,
  }
  if attr.scope == File {
    if attr.name == ".FileFunction" && state.first_operation_seen {
      add_error(
        state,
        "G230",
        "FileFunction must be defined in the header.",
        Some(line),
      )
    }
    match state.file_attributes.get(attr.name) {
      Some(_) =>
        add_error(state, "G231", "Duplicate file attribute.", Some(line))
      None => {
        let value = match attr.raw_value {
          Some(v) => v
          None => ""
        }
        state.file_attributes.set(attr.name, value)
        if attr.name == ".FileFunction" {
          if state.file_function is None {
            state.file_function = attr.raw_value
          }
        } else if attr.name == ".FilePolarity" {
          if state.file_polarity is None {
            state.file_polarity = attr.raw_value
          }
        } else if attr.name == ".GenerationSoftware" {
          if state.generation_software is None {
            state.generation_software = attr.raw_value
          }
        }
      }
    }
  }
}

///|
fn check_end_file(state : CheckerState, line : Int) -> Unit {
  if state.region_open {
    // M02 inside region is disallowed
    add_error(
      state,
      "G223",
      "Command is not allowed inside a region.",
      Some(line),
    )
  }
  state.end_count = state.end_count + 1
  if state.end_count > 1 {
    add_error(state, "G132", "Duplicate M02.", Some(line))
  }
  state.end_seen = true
}

///|
fn check_unknown(
  state : CheckerState,
  _raw : String,
  line : Int,
  _extended : Bool,
) -> Unit {
  add_warning(state, "G302", "Unsupported Gerber command.", Some(line))
  state.statistics = {
    ..state.statistics,
    unknown_commands: state.statistics.unknown_commands + 1,
  }
  if state.region_open {
    add_error(
      state,
      "G223",
      "Command is not allowed inside a region.",
      Some(line),
    )
  }
}

///|
fn finalize_report(state : CheckerState) -> GerberReport {
  // Global checks in fixed order: G110, G120, G130, G222, G301
  if state.unit_seen_count == 0 {
    add_error(state, "G110", "Missing unit statement.", None)
  }
  if state.format_seen_count == 0 {
    add_error(state, "G120", "Missing format specification.", None)
  }
  if state.end_count == 0 {
    add_error(state, "G130", "Missing M02 end-of-file command.", None)
  }
  if state.region_open {
    add_error(state, "G222", "Unclosed region.", None)
  }
  if state.file_function is None {
    add_warning(state, "G301", "X2 FileFunction attribute was not found.", None)
  }
  let issues = state.issues
  {
    status: compute_status(issues),
    unit: state.unit,
    coordinate_format: state.coordinate_format,
    file_function: state.file_function,
    file_polarity: state.file_polarity,
    generation_software: state.generation_software,
    statistics: state.statistics,
    issues,
  }
}

///|
pub fn analyze(source : String) -> GerberReport {
  let scan = scan_source(source)
  if scan.fatal {
    return {
      status: Fail,
      unit: None,
      coordinate_format: None,
      file_function: None,
      file_polarity: None,
      generation_software: None,
      statistics: empty_statistics(),
      issues: scan.issues,
    }
  }
  let parsed = parse_tokens(scan.tokens)
  check_commands(parsed.commands, parsed.issues, parsed.issue_command_indices)
}

///|
pub fn input_too_large_report() -> GerberReport {
  let issues : Array[Issue] = [
    {
      code: "G001",
      severity: Error,
      message: "Input file exceeds 64 MiB limit.",
      line: None,
    },
  ]
  {
    status: Fail,
    unit: None,
    coordinate_format: None,
    file_function: None,
    file_polarity: None,
    generation_software: None,
    statistics: empty_statistics(),
    issues,
  }
}