///|
/// TLS is mandatory by default. Plaintext is only for explicitly trusted local
/// networks or an external authenticated tunnel; credentials are never allowed.
pub(all) enum Security {
  Tls
  TlsCaFile(String)
  Plaintext
}

///|
/// Each operation owns a fresh connection, so failures and concurrent calls
/// cannot desynchronize a shared protocol stream.
pub struct Client {
  priv host : String
  priv port : Int32
  priv security : Security
  priv username : String?
  priv password : String?
  priv timeout_ms : Int32
  priv max_line_bytes : Int32
  priv max_rows : Int32
  priv max_response_bytes : Int32
}

///|
/// The timeout covers DNS, connection, TLS, authentication and the whole query.
pub fn Client::new(
  host : String,
  port~ : Int32 = 3493,
  security~ : Security = Tls,
  username? : String,
  password? : String,
  timeout_ms~ : Int32 = 5000,
  max_line_bytes~ : Int32 = 65536,
  max_rows~ : Int32 = 4096,
  max_response_bytes~ : Int32 = 4194304,
) -> Client raise NutError {
  if host.is_empty() || port < 1 || port > 65535 {
    raise InvalidArgument("invalid host or port")
  }
  if timeout_ms <= 0 ||
    max_line_bytes < 16 ||
    max_line_bytes > 1048576 ||
    max_rows < 1 ||
    max_rows > 65536 ||
    max_response_bytes < max_line_bytes ||
    max_response_bytes > 67108864 {
    raise InvalidArgument("invalid resource limits")
  }
  ignore(quote_argument(host))
  match (username, password) {
    (Some(u), Some(p)) => {
      if security is Plaintext {
        raise InvalidArgument("credentials require verified TLS")
      }
      ignore(quote_argument(u))
      ignore(quote_argument(p))
      if u.is_empty() {
        raise InvalidArgument("username must not be empty")
      }
    }
    (None, None) => ()
    _ =>
      raise InvalidArgument("username and password must be supplied together")
  }
  {
    host,
    port,
    security,
    username,
    password,
    timeout_ms,
    max_line_bytes,
    max_rows,
    max_response_bytes,
  }
}

///|
priv enum Transport {
  Clear(@socket.Tcp)
  Secure(@tls.Tls)
}

///|
priv struct Session {
  mut transport : Transport
  mut pending : Bytes
  mut offset : Int32
  mut received : Int32
  max_response_bytes : Int32
  max_line_bytes : Int32
}

///|
async fn Session::write(self : Session, line : String) -> Unit {
  match self.transport {
    Clear(tcp) => tcp.write(line + "\n")
    Secure(tls) => tls.write(line + "\n")
  }
}

///|
/// One-byte STARTTLS reads prevent reading encrypted data into a cleartext
/// buffer. Normal replies use a bounded buffer and retain fragmented lines.
async fn Session::line(
  self : Session,
  handshake~ : Bool = false,
) -> Array[String] {
  let buffer = @buffer.Buffer()
  while true {
    if self.offset == self.pending.length() {
      let chunk = match self.transport {
        Clear(tcp) => tcp.read_some(max_len=if handshake { 1 } else { 4096 })
        Secure(tls) => tls.read_some(max_len=4096)
      }
      match chunk {
        None => raise UnexpectedEof
        Some(bytes) => {
          self.pending = bytes
          self.offset = 0
        }
      }
    }
    let byte = self.pending[self.offset]
    self.offset += 1
    self.received += 1
    if self.received > self.max_response_bytes {
      raise LimitExceeded("response")
    }
    if byte == b'\n' {
      let bytes = buffer.to_bytes()
      let len = bytes.length()
      let text = try {
        if len > 0 && bytes[len - 1] == b'\r' {
          @utf8.decode(bytes[:len - 1])
        } else {
          @utf8.decode(bytes)
        }
      } catch {
        _ => raise Protocol("invalid UTF-8 response")
      }
      let tokens = parse_line(text)
      if tokens.is_empty() {
        raise Protocol("empty response")
      }
      check_server(tokens)
      return tokens
    }
    if buffer.length() >= self.max_line_bytes {
      raise LimitExceeded("line")
    }
    buffer.write_byte(byte)
  } nobreak {
    raise UnexpectedEof
  }
}

///|
async fn Client::query(
  self : Client,
  command : String,
  header : Array[String],
  list : Bool,
) -> Array[Array[String]] {
  @async.with_timeout(self.timeout_ms, () => {
    let tcp = @socket.Tcp::connect_to_host(self.host, port=self.port)
    defer tcp.close()
    let session : Session = {
      transport: Clear(tcp),
      pending: b"",
      offset: 0,
      received: 0,
      max_response_bytes: self.max_response_bytes,
      max_line_bytes: self.max_line_bytes,
    }
    match self.security {
      Plaintext => ()
      _ => {
        session.write("STARTTLS")
        expect(session.line(handshake=true), ["OK", "STARTTLS"])
        let trust = match self.security {
          TlsCaFile(path) => @tls.CustomPemFile(path)
          _ => @tls.SystemRoot
        }
        let tls = @tls.Tls::client(tcp, host=self.host, trust~)
        session.transport = Secure(tls)
      }
    }
    defer (match session.transport {
      Secure(tls) => tls.close()
      _ => ()
    })
    match (self.username, self.password) {
      (Some(user), Some(pass)) => {
        session.write("USERNAME " + quote_argument(user))
        expect(session.line(), ["OK"])
        session.write("PASSWORD " + quote_argument(pass))
        expect(session.line(), ["OK"])
      }
      _ => ()
    }
    session.write(command)
    if !list {
      return [session.line()]
    }
    let begin = ["BEGIN", "LIST"] + header
    let end = ["END", "LIST"] + header
    expect(session.line(), begin)
    let rows : Array[Array[String]] = []
    while true {
      let row = session.line()
      if row == end {
        return rows
      }
      if rows.length() >= self.max_rows {
        raise LimitExceeded("rows")
      }
      if row.length() != header.length() + 2 {
        raise Protocol("invalid list row shape")
      }
      for i in 0.. Array[Ups] {
  let rows = self.query("LIST UPS", ["UPS"], true)
  rows.map(row => {
    match row {
      ["UPS", name, description] => { name, description, }
      _ => raise Protocol("invalid UPS list row")
    }
  })
}

///|
pub async fn Client::list_vars(self : Client, ups : String) -> Array[Variable] {
  let rows = self.query("LIST VAR " + quote_argument(ups), ["VAR", ups], true)
  rows.map(row => {
    match row {
      ["VAR", device, name, value] if device == ups => { name, value, }
      _ => raise Protocol("invalid variable list row")
    }
  })
}

///|
pub async fn Client::get_var(
  self : Client,
  ups : String,
  name : String,
) -> String {
  let rows = self.query(
    "GET VAR " + quote_argument(ups) + " " + quote_argument(name),
    [],
    false,
  )
  match rows[0] {
    ["VAR", device, key, value] if device == ups && key == name => value
    _ => raise Protocol("invalid variable response")
  }
}

///|
pub extend Ups with Eq::{equal, not_equal}

///|
pub extend Ups with @debug.Debug::{to_repr}

///|
pub extend Variable with Eq::{equal, not_equal}

///|
pub extend Variable with @debug.Debug::{to_repr}