///|
/// The cardinality and merge rule used for a supported OpenSSH directive.
pub(all) enum MergeKind {
  FirstSet
  Append
  SetEnv
} derive(Debug, Eq)

///|
/// The validation class for a directive value.  The resolver deliberately
/// keeps this small: values that it cannot validate are never executed.
pub(all) enum ValueKind {
  Text
  Boolean
  Port
  NonNegativeInt
  Duration
  StrictHostKeyChecking
  AddressFamily
  LogLevel
  AddKeysToAgent
} derive(Debug, Eq)

///|
pub(all) struct DirectiveSpec {
  keyword : String
  merge : MergeKind
  value_kind : ValueKind
  allowed_tokens : Array[String]
  sensitive : Bool
} derive(Debug, Eq)

///|
fn lower_ascii(value : String) -> String {
  let output = StringBuilder()
  for char in value {
    output.write_char(char.to_ascii_lowercase())
  }
  output.to_string()
}

///|
/// Canonical OpenSSH directive spelling used by all resolver maps and lookup
/// APIs. OpenSSH directive names are ASCII case-insensitive.
pub fn canonical_keyword(value : String) -> String {
  lower_ascii(value)
}

///|
fn spec(
  keyword : String,
  merge : MergeKind,
  value_kind : ValueKind,
  allowed_tokens? : Array[String] = [],
  sensitive? : Bool = false,
) -> DirectiveSpec {
  { keyword, merge, value_kind, allowed_tokens, sensitive }
}

///|
/// Return the P0 specification for `keyword`. Unknown directives are kept in
/// the syntax tree and appear as an `Unsupported` trace event instead of being
/// silently treated as a scalar setting.
pub fn directive_spec(keyword : String) -> DirectiveSpec? {
  match canonical_keyword(keyword) {
    // OpenSSH accepts only %% and %h in HostName. `%%` is handled by the
    // common expander, so it is intentionally not listed here.
    "hostname" => Some(spec("hostname", FirstSet, Text, allowed_tokens=["h"]))
    // Unlike path/command directives, User does not accept OpenSSH tokens.
    "user" => Some(spec("user", FirstSet, Text))
    "port" => Some(spec("port", FirstSet, Port))
    "identitiesonly" => Some(spec("identitiesonly", FirstSet, Boolean))
    "proxyjump" =>
      Some(
        spec("proxyjump", FirstSet, Text, allowed_tokens=["h", "n", "p", "r"]),
      )
    "proxycommand" =>
      Some(
        spec(
          "proxycommand",
          FirstSet,
          Text,
          allowed_tokens=["h", "n", "p", "r"],
          sensitive=true,
        ),
      )
    "connecttimeout" => Some(spec("connecttimeout", FirstSet, Duration))
    "serveraliveinterval" =>
      Some(spec("serveraliveinterval", FirstSet, Duration))
    "serveralivecountmax" =>
      Some(spec("serveralivecountmax", FirstSet, NonNegativeInt))
    "stricthostkeychecking" =>
      Some(spec("stricthostkeychecking", FirstSet, StrictHostKeyChecking))
    "forwardagent" => Some(spec("forwardagent", FirstSet, Boolean))
    "addkeystoagent" => Some(spec("addkeystoagent", FirstSet, AddKeysToAgent))
    "compression" => Some(spec("compression", FirstSet, Boolean))
    "addressfamily" => Some(spec("addressfamily", FirstSet, AddressFamily))
    "loglevel" => Some(spec("loglevel", FirstSet, LogLevel))
    "identityfile" =>
      Some(
        spec("identityfile", Append, Text, allowed_tokens=[
          "h", "n", "p", "r", "u", "d",
        ]),
      )
    "certificatefile" =>
      Some(
        spec("certificatefile", Append, Text, allowed_tokens=[
          "h", "n", "p", "r", "u", "d",
        ]),
      )
    "userknownhostsfile" =>
      Some(
        spec("userknownhostsfile", FirstSet, Text, allowed_tokens=[
          "h", "n", "p", "r", "u", "d",
        ]),
      )
    "localforward" =>
      Some(
        spec("localforward", Append, Text, allowed_tokens=[
          "h", "n", "p", "r", "u", "d",
        ]),
      )
    "remoteforward" =>
      Some(
        spec("remoteforward", Append, Text, allowed_tokens=[
          "h", "n", "p", "r", "u", "d",
        ]),
      )
    "dynamicforward" => Some(spec("dynamicforward", Append, Text))
    "sendenv" => Some(spec("sendenv", Append, Text))
    "setenv" => Some(spec("setenv", SetEnv, Text, sensitive=true))
    _ => None
  }
}

///|
fn is_member(value : String, allowed : Array[String]) -> Bool {
  for item in allowed {
    if value == item {
      return true
    }
  }
  false
}

///|
fn validates_enum(value : String, allowed : Array[String]) -> Bool {
  is_member(lower_ascii(value), allowed)
}

///|
let maximum_duration_seconds : Int = 2_147_483_647

///|
fn duration_multiplier(code : Int) -> Int? {
  match code {
    115 => Some(1) // s
    109 => Some(60) // m
    104 => Some(60 * 60) // h
    100 => Some(24 * 60 * 60) // d
    119 => Some(7 * 24 * 60 * 60) // w
    _ => None
  }
}

///|
/// Parse the OpenSSH time format used by ssh_config(5): an unsigned number of
/// seconds, or a sequence such as `1h30m`. Arithmetic is checked before every
/// multiply/add so malformed configuration cannot wrap an Int.
fn parse_duration_seconds(value : String) -> Int? {
  if value.is_empty() {
    return None
  }
  let mut index = 0
  let mut total = 0
  while index < value.length() {
    let start = index
    let mut number = 0
    while index < value.length() {
      let code = value[index].to_int()
      if code < 48 || code > 57 {
        break
      }
      let digit = code - 48
      if number > (maximum_duration_seconds - digit) / 10 {
        return None
      }
      number = number * 10 + digit
      index += 1
    }
    if index == start {
      return None
    }
    let multiplier = if index < value.length() {
      match duration_multiplier(value[index].to_int()) {
        Some(unit) => {
          index += 1
          unit
        }
        None => return None
      }
    } else {
      1
    }
    if number > maximum_duration_seconds / multiplier {
      return None
    }
    let seconds = number * multiplier
    if total > maximum_duration_seconds - seconds {
      return None
    }
    total += seconds
  }
  Some(total)
}

///|
fn normalize_add_keys_to_agent(value : String) -> Result[String, String] {
  let parts = [ for part in value.split(" ") => part.to_owned() ]
  if parts.length() == 1 {
    let token = lower_ascii(parts[0])
    if is_member(token, ["yes", "no", "confirm", "ask"]) {
      return Ok(token)
    }
    match parse_duration_seconds(token) {
      Some(seconds) => Ok(seconds.to_string())
      None =>
        Err(
          "expected yes, no, confirm, ask, a duration, or confirm plus a duration",
        )
    }
  } else if parts.length() == 2 && lower_ascii(parts[0]) == "confirm" {
    match parse_duration_seconds(parts[1]) {
      Some(seconds) => Ok("confirm \{seconds}")
      None => Err("expected a valid duration after confirm")
    }
  } else {
    Err(
      "expected yes, no, confirm, ask, a duration, or confirm plus a duration",
    )
  }
}

///|
fn normalize_value(
  specification : DirectiveSpec,
  value : String,
) -> Result[String, String] {
  match specification.value_kind {
    Text => Ok(value)
    Boolean => {
      let token = lower_ascii(value)
      match token {
        "yes" => Ok("yes")
        "no" => Ok("no")
        "true" if specification.keyword != "compression" => Ok("yes")
        "false" if specification.keyword != "compression" => Ok("no")
        _ => Err("expected an OpenSSH boolean value")
      }
    }
    Port => {
      let parsed = @strconv.from_str(value) catch { _ => -1 }
      if parsed >= 1 && parsed <= 65535 {
        Ok(parsed.to_string())
      } else {
        Err("expected an integer in 1..65535")
      }
    }
    NonNegativeInt => {
      let parsed = @strconv.from_str(value) catch { _ => -1 }
      if parsed >= 0 {
        Ok(parsed.to_string())
      } else {
        Err("expected a non-negative integer")
      }
    }
    Duration => {
      let token = lower_ascii(value)
      if token == "none" && specification.keyword == "connecttimeout" {
        Ok("none")
      } else if token == "none" &&
        specification.keyword == "serveraliveinterval" {
        Ok("0")
      } else {
        match parse_duration_seconds(token) {
          Some(seconds) => Ok(seconds.to_string())
          None => Err("expected a non-negative OpenSSH time value")
        }
      }
    }
    StrictHostKeyChecking =>
      match lower_ascii(value) {
        "yes" | "true" => Ok("true")
        "no" | "false" | "off" => Ok("false")
        "ask" => Ok("ask")
        "accept-new" => Ok("accept-new")
        _ => Err("expected yes, ask, no, accept-new, or off")
      }
    AddressFamily =>
      if validates_enum(value, ["any", "inet", "inet6"]) {
        Ok(lower_ascii(value))
      } else {
        Err("expected any, inet, or inet6")
      }
    LogLevel =>
      match lower_ascii(value) {
        "quiet" => Ok("SILENT")
        "fatal" => Ok("FATAL")
        "error" => Ok("ERROR")
        "info" => Ok("INFO")
        "verbose" => Ok("VERBOSE")
        "debug" | "debug1" => Ok("DEBUG")
        "debug2" => Ok("DEBUG2")
        "debug3" => Ok("DEBUG3")
        _ => Err("expected an OpenSSH log level")
      }
    AddKeysToAgent => normalize_add_keys_to_agent(value)
  }
}

///|
/// Validate a directive's joined textual representation.
pub fn validate_value(specification : DirectiveSpec, value : String) -> String? {
  match normalize_value(specification, value) {
    Ok(_) => None
    Err(message) => Some(message)
  }
}

///|
/// Normalize a value that has already passed `validate_value`. Duration-bearing
/// directives are rendered as seconds to match `ssh -G`; all other classes
/// preserve their parsed spelling.
fn normalized_value(specification : DirectiveSpec, value : String) -> String {
  match normalize_value(specification, value) {
    Ok(normalized) => normalized
    Err(_) => value
  }
}

///|
fn valid_port_text(value : String) -> Bool {
  let parsed : Int = @strconv.from_str(value) catch { _ => -1 }
  parsed >= 1 && parsed <= 65535
}

///|
fn valid_remote_port_text(value : String) -> Bool {
  let parsed : Int = @strconv.from_str(value) catch { _ => -1 }
  parsed >= 0 && parsed <= 65535
}

///|
fn valid_listen_endpoint(value : String) -> Bool {
  if value.contains("%") {
    return true
  }
  if valid_port_text(value) {
    return true
  }
  match value.rev_find(":") {
    Some(index) if index > 0 && index + 1 < value.length() =>
      valid_port_text(value[index + 1:].to_owned())
    _ => false
  }
}

///|
fn valid_remote_listen_endpoint(value : String) -> Bool {
  if value.contains("%") {
    return true
  }
  if valid_remote_port_text(value) {
    return true
  }
  match value.rev_find(":") {
    Some(index) if index > 0 && index + 1 < value.length() =>
      valid_remote_port_text(value[index + 1:].to_owned())
    _ => false
  }
}

///|
fn valid_socket_path(value : String) -> Bool {
  value.contains("/") && value != "/"
}

///|
fn valid_forward_target(value : String) -> Bool {
  if value.contains("%") || valid_socket_path(value) {
    return true
  }
  match value.rev_find(":") {
    Some(index) if index > 0 && index + 1 < value.length() =>
      valid_port_text(value[index + 1:].to_owned())
    _ => false
  }
}

///|
fn setenv_argument_error(arguments : Array[String]) -> String? {
  for argument in arguments {
    match argument.find("=") {
      Some(index) if index > 0 => ()
      _ => return Some("SetEnv requires NAME=value with a non-empty name")
    }
  }
  None
}

///|
fn forward_argument_error(
  keyword : String,
  arguments : Array[String],
) -> String? {
  match keyword {
    "localforward" =>
      if arguments.length() != 2 {
        Some("LocalForward requires a listen endpoint and a target")
      } else if !valid_listen_endpoint(arguments[0]) &&
        !valid_socket_path(arguments[0]) {
        Some("invalid LocalForward listen endpoint")
      } else if !valid_forward_target(arguments[1]) {
        Some("invalid LocalForward target")
      } else {
        None
      }
    "remoteforward" =>
      if arguments.length() < 1 || arguments.length() > 2 {
        Some("RemoteForward requires a listen endpoint and optional target")
      } else if !valid_remote_listen_endpoint(arguments[0]) &&
        !(arguments.length() == 2 && valid_socket_path(arguments[0])) {
        Some("invalid RemoteForward listen endpoint")
      } else if arguments.length() == 2 && !valid_forward_target(arguments[1]) {
        Some("invalid RemoteForward target")
      } else {
        None
      }
    "dynamicforward" =>
      if arguments.length() != 1 {
        Some("DynamicForward requires exactly one listen endpoint")
      } else if !valid_listen_endpoint(arguments[0]) {
        Some("invalid DynamicForward listen endpoint")
      } else {
        None
      }
    _ => None
  }
}

///|
/// Validate argument cardinality and directive-specific grammar independently
/// of Host/Match selection. This is shared by the resolver and static lint so
/// invalid values cannot hide in an unselected block.
pub fn validate_directive_arguments(
  keyword : String,
  arguments : Array[String],
) -> String? {
  let normalized = canonical_keyword(keyword)
  guard directive_spec(normalized) is Some(specification) else { return None }
  if arguments.is_empty() {
    return Some("directive requires at least one argument")
  }
  match normalized {
    "proxycommand" | "sendenv" | "userknownhostsfile" => ()
    "setenv" => return setenv_argument_error(arguments)
    "localforward" | "remoteforward" | "dynamicforward" =>
      match forward_argument_error(normalized, arguments) {
        Some(message) => return Some(message)
        None => ()
      }
    "addkeystoagent" =>
      if arguments.length() > 2 {
        return Some("AddKeysToAgent accepts at most two arguments")
      }
    _ =>
      if arguments.length() != 1 {
        return Some("directive accepts exactly one argument")
      }
  }
  validate_value(specification, arguments.join(" "))
}

///|
fn normalized_directive_value(
  specification : DirectiveSpec,
  arguments : Array[String],
) -> String {
  normalized_value(specification, arguments.join(" "))
}