///|
/// File CLI commands; parsing is pure and the process boundary owns filesystem IO.
pub(all) enum CommandKind {
  Help
  VersionCommand
  Inspect
  Validate
  Decode
  Encode
  Diff
  Layout
  Normalize
} derive(Eq, @debug.Debug)

///|
/// Checked CLI arguments, including explicit raw/physical mode.
pub(all) struct Command {
  kind : CommandKind
  path : String
  second_path : String?
  message_id : UInt?
  hex : String
  assignments : Array[String]
  raw : Bool
  permissive : Bool
  json : Bool
} derive(Eq, @debug.Debug)

///|
/// Text output and conventional status: 0 success, 1 validation/diff, 2 usage/error.
pub(all) struct CliOutput {
  text : String
  exit_code : Int
} derive(Eq, @debug.Debug)

///|
/// Parse user arguments without reading files. IDs use decimal or 0x notation.
pub fn parse_cli_arguments(args : Array[String]) -> Command raise DbcError {
  let mut raw = false
  let mut permissive = false
  let mut json = false
  let positional = []
  let mut message = None
  let mut i = 0
  while i < args.length() {
    match args[i] {
      "--raw" => raw = true
      "--permissive" => permissive = true
      "--json" => json = true
      "--message" => {
        i += 1
        if i >= args.length() {
          fail("cli.arguments", "--message requires an ID")
        }
        message = Some(parse_cli_id(args[i]))
      }
      a => {
        if a.has_prefix("--") && a != "--help" && a != "--version" {
          fail("cli.arguments", "unknown flag \{a}")
        }
        positional.push(a)
      }
    }
    i += 1
  }
  let first = if positional.length() == 0 { "help" } else { positional[0] }
  let kind = match first {
    "help" | "--help" => Help
    "version" | "--version" => VersionCommand
    "inspect" => Inspect
    "validate" => Validate
    "decode" => Decode
    "encode" => Encode
    "diff" => Diff
    "layout" => Layout
    "normalize" => Normalize
    _ => {
      fail("cli.arguments", "unknown command \{first}")
      Help
    }
  }
  let min_args = match kind {
    Help | VersionCommand => 1
    Inspect | Validate | Normalize => 2
    Diff | Layout | Encode => 3
    Decode => 4
  }
  if kind != Help && positional.length() < min_args {
    fail("cli.arguments", "missing command arguments")
  }
  if kind != Encode && positional.length() > min_args {
    fail("cli.arguments", "unexpected positional argument")
  }
  if raw && kind != Encode {
    fail("cli.arguments", "--raw is only valid for encode")
  }
  if json && kind != Decode {
    fail("cli.arguments", "--json is currently supported by decode")
  }
  if message is Some(_) && kind != Inspect {
    fail("cli.arguments", "--message is only valid for inspect")
  }
  let path = if min_args >= 2 { positional[1] } else { "" }
  let message_id = match kind {
    Decode | Encode | Layout => Some(parse_cli_id(positional[2]))
    _ => message
  }
  let second_path = if kind == Diff { Some(positional[2]) } else { None }
  let hex = if kind == Decode { positional[3] } else { "" }
  let assignments = if kind == Encode { positional[3:].to_owned() } else { [] }
  {
    kind,
    path,
    second_path,
    message_id,
    hex,
    assignments,
    raw,
    permissive,
    json,
  }
}

///|
fn parse_cli_id(text : String) -> UInt raise DbcError {
  @string.parse_uint(text) catch {
    _ => {
      fail("cli.id", "invalid 32-bit ID: \{text}")
      0U
    }
  }
}

///|
/// Parse hexadecimal payload bytes, rejecting odd lengths and invalid characters.
pub fn parse_frame_hex(text : String) -> Array[Byte] raise DbcError {
  if text.length() % 2 != 0 || text.length() > 128 {
    fail("cli.hex", "payload must contain 0..64 complete hexadecimal bytes")
  }
  let bytes = []
  for i = 0; i < text.length(); i = i + 2 {
    let hi = hex_nibble(text[i].to_int())
    let lo = hex_nibble(text[i + 1].to_int())
    if hi < 0 || lo < 0 {
      fail("cli.hex", "invalid hexadecimal digit")
    }
    bytes.push((hi * 16 + lo).to_byte())
  }
  bytes
}

///|
fn hex_nibble(c : Int) -> Int {
  if c >= 48 && c <= 57 {
    c - 48
  } else if c >= 65 && c <= 70 {
    c - 55
  } else if c >= 97 && c <= 102 {
    c - 87
  } else {
    -1
  }
}

///|
/// Render payload bytes as uppercase hex without separators.
pub fn frame_hex(payload : Array[Byte]) -> String {
  let alphabet = "0123456789ABCDEF"
  let out = StringBuilder::new()
  for b in payload {
    let n = b.to_int()
    out.write_stringview(alphabet[n / 16:n / 16 + 1])
    out.write_stringview(alphabet[n % 16:n % 16 + 1])
  }
  out.to_string()
}

///|
/// Render stable diagnostic context for terminals/logs.
pub fn render_diagnostic(d : Diagnostic) -> String {
  let severity = if d.severity == Error { "error" } else { "warning" }
  let object = d.message_name.unwrap_or("") +
    (match d.signal_name {
      Some(s) => "." + s
      None => ""
    })
  "\{severity} [\{d.code}] line \{d.location.line}:\{d.location.column} \{object}: \{d.message}"
}

///|
/// Usage text shared by executable and CLI tests.
pub fn cli_help() -> String {
  "moon-dbc 0.1.0\nUsage:\n  moon-dbc inspect FILE [--message DBC_ID]\n  moon-dbc validate FILE\n  moon-dbc decode FILE DBC_ID HEX [--json]\n  moon-dbc encode FILE DBC_ID [--raw] Signal=value ...\n  moon-dbc layout FILE DBC_ID\n  moon-dbc diff OLD NEW\n  moon-dbc normalize FILE\nAll file commands accept --permissive. IDs: decimal or 0x, extended DBC IDs include bit 31.\nPhysical inputs are numbers; enum inputs use label:Name. Raw inputs are exact integers.\n"
}

///|
fn cli_message(db : Database, id : UInt?) -> Message raise DbcError {
  let raw_id = match id {
    Some(v) => v
    None => {
      fail("cli.id", "message ID required")
      0U
    }
  }
  match db.message_by_id(frame_id_from_dbc(raw_id)) {
    Some(m) => m
    None => {
      fail("codec.unknown_id", "unknown DBC message ID \{raw_id}")
      Message::new({ id: 0U, extended: false }, "", 0)
    }
  }
}

///|
fn parse_assignment(
  message : Message,
  text : String,
  raw : Bool,
) -> SignalAssignment raise DbcError {
  let split = match text.find("=") {
    Some(i) => i
    None => {
      fail("cli.assignment", "expected Signal=value")
      0
    }
  }
  let name = text[:split].to_owned()
  let literal = text[split + 1:].to_owned()
  let s = match message.signal_by_name(name) {
    Some(v) => v
    None => {
      fail("codec.unknown_signal", "unknown signal \{name}")
      Signal::new("", 0, 1)
    }
  }
  let value = if raw {
    if literal.has_prefix("label:") {
      fail("cli.assignment", "labels are physical-mode inputs")
    }
    if s.signed {
      Raw(
        Signed(
          @string.parse_int64(literal) catch {
            _ => {
              fail("cli.assignment", "invalid signed raw integer")
              0L
            }
          },
        ),
      )
    } else {
      Raw(
        Unsigned(
          @string.parse_uint64(literal) catch {
            _ => {
              fail("cli.assignment", "invalid unsigned raw integer")
              0UL
            }
          },
        ),
      )
    }
  } else if literal.has_prefix("label:") {
    Label(literal[6:].to_owned())
  } else {
    let n = @string.parse_double(literal) catch {
      _ => {
        fail("cli.assignment", "invalid physical number")
        0.0
      }
    }
    if !finite(n) {
      fail("cli.assignment", "physical number must be finite")
    }
    Physical(n)
  }
  { name, value }
}

///|
fn raw_text(raw : RawValue) -> String {
  match raw {
    Signed(v) => v.to_string()
    Unsigned(v) => v.to_string()
  }
}

///|
fn decoded_text(decoded : DecodedMessage) -> String {
  let lines = ["Message: \{decoded.name} (DBC ID \{decoded.frame_id.to_dbc()})"]
  for s in decoded.signals {
    lines.push(
      "\{s.name}: raw=\{raw_text(s.raw)} value=\{s.physical} \{s.unit}" +
      (match s.label {
        Some(v) => " (" + v + ")"
        None => ""
      }),
    )
  }
  for d in decoded.diagnostics {
    lines.push(render_diagnostic(d))
  }
  lines.join("\n")
}

///|
fn decoded_json(decoded : DecodedMessage) -> String {
  // Raw integers are strings to preserve 64-bit precision in JavaScript consumers.
  let rows = decoded.signals.map(fn(s) {
    "{\"name\":\{quote_dbc(s.name)},\"raw\":\{quote_dbc(raw_text(s.raw))},\"physical\":\{s.physical},\"unit\":\{quote_dbc(s.unit)},\"label\":" +
    (match s.label {
      Some(v) => quote_dbc(v)
      None => "null"
    }) +
    ",\"out_of_range\":\{s.out_of_range}}"
  })
  let diagnostics = decoded.diagnostics.map(fn(d) {
    "{\"code\":\{quote_dbc(d.code)},\"message\":\{quote_dbc(d.message)}}"
  })
  "{\"message\":\{quote_dbc(decoded.name)},\"dbc_id\":\{decoded.frame_id.to_dbc()},\"signals\":[" +
  rows.join(",") +
  "],\"diagnostics\":[" +
  diagnostics.join(",") +
  "]}"
}

///|
/// Execute a parsed CLI command on caller-supplied file contents; no IO in core.
pub fn execute_cli(
  command : Command,
  source : String,
  second_source? : String = "",
) -> CliOutput raise DbcError {
  if command.kind == Help {
    return { text: cli_help(), exit_code: 0 }
  }
  if command.kind == VersionCommand {
    return { text: "moon-dbc 0.1.0", exit_code: 0 }
  }
  let options = ParseOptions::{
    mode: if command.permissive {
      Permissive
    } else {
      Strict
    },
    limits: ParseLimits::default(),
  }
  let db = parse_dbc_with_options(source, options)
  let findings = validate_database(db)
  let errors = findings.filter(fn(d) { d.severity == Error }).length()
  if command.kind == Validate {
    let lines = findings.map(render_diagnostic)
    lines.push(
      "Validation: \{errors} errors, \{findings.length()-errors} warnings",
    )
    return {
      text: lines.join("\n"),
      exit_code: if errors == 0 {
        0
      } else {
        1
      },
    }
  }
  if command.kind != Inspect && errors > 0 {
    raise Failure(
      findings.iter().find_first(fn(d) { d.severity == Error }).unwrap(),
    )
  }
  let text = match command.kind {
    Inspect => {
      let s = inspect_database(db)
      let summary = "DBC database\nVersion: \{db.version}\nNodes: \{s.nodes}\nMessages: \{s.messages}\nSignals: \{s.signals}\nStandard CAN messages: \{s.standard_messages}\nExtended CAN messages: \{s.extended_messages}\nClassic-sized messages: \{s.classic_sized_messages}\nCAN FD-sized messages: \{s.fd_sized_messages}\nMultiplexed messages: \{s.multiplexed_messages}\nValue descriptions: \{s.value_descriptions}\nDiagnostics: \{s.errors} errors, \{s.warnings} warnings"
      if command.message_id is Some(_) {
        summary + "\n" + render_layout(cli_message(db, command.message_id))
      } else {
        summary
      }
    }
    Decode => {
      let m = cli_message(db, command.message_id)
      let decoded = decode_frame(db, {
        frame_id: m.frame_id,
        payload: parse_frame_hex(command.hex),
      })
      if command.json {
        decoded_json(decoded)
      } else {
        decoded_text(decoded)
      }
    }
    Encode => {
      let m = cli_message(db, command.message_id)
      let assignments = command.assignments.map(fn(text) raise DbcError {
        parse_assignment(m, text, command.raw)
      })
      let result = encode_message(m, assignments)
      let warnings = result.diagnostics.map(render_diagnostic)
      frame_hex(result.frame.payload) +
      (if warnings.length() > 0 { "\n" + warnings.join("\n") } else { "" })
    }
    Layout => render_layout(cli_message(db, command.message_id))
    Normalize => write_dbc(db)
    Diff => {
      let other = parse_dbc_with_options(second_source, options)
      for d in validate_database(other) {
        if d.severity == Error {
          raise Failure(d)
        }
      }
      let changes = diff_database(db, other)
      let rows = changes.map(fn(c) {
        let context = c.message_name.unwrap_or("database") +
          (match c.signal_name {
            Some(n) => "." + n
            None => ""
          })
        "\{context}.\{c.field}: \{c.before} -> \{c.after}"
      })
      return {
        text: "Semantic changes: \{changes.length()}" +
        (if rows.length() > 0 { "\n" + rows.join("\n") } else { "" }),
        exit_code: if changes.length() == 0 {
          0
        } else {
          1
        },
      }
    }
    Help | VersionCommand | Validate => ""
  }
  { text, exit_code: if errors == 0 { 0 } else { 1 } }
}