// Copyright 2026 PaiGack
// Licensed under the Apache License, Version 2.0.
// Ported from jlaffaye/ftp (ISC License), see LICENSE-THIRD-PARTY.

// Layer: IO — uses the `moonbitlang/async` stack from the root moon.pkg.

// Making a connection: dial, upgrade to TLS, then log in and learn the
// server's capabilities.
//
// This file merges `dial` and `login`: `login` is the second half of `dial`
// (every caller runs them back to back) and it is the only consumer of the
// `Session` that `dial` builds, including the `FEAT` handshake whose result the
// rest of the client reads. Address split, TLS negotiation and authentication
// are one "get me a usable connection" story.

///|
/// Connect to `addr` (`host` or `host:port`) and return a ready client.
///
/// The options mirror the Go `DialWith*` family; they are labels rather than
/// a function-options variadic, which is the MoonBit idiom recorded in
/// `docs/porting/04-api-mapping.md`.
///
/// Once connected the client reads the greeting (expecting `220`). When
/// `explicit_tls` is set it then sends `AUTH TLS`, expects `234` and upgrades
/// the control connection.
pub async fn dial(
  addr : String,
  timeout_ms? : Int = default_dial_timeout_ms,
  shut_timeout_ms? : Int = 0,
  tls? : Bool = false,
  explicit_tls? : Bool = false,
  trust? : @tls.TrustedRoot = @tls.TrustedRoot::SystemRoot,
  disable_epsv? : Bool = false,
  trust_pasv_ip? : Bool = false,
  disable_utf8? : Bool = false,
  disable_mlsd? : Bool = false,
  writing_mdtm? : Bool = false,
  force_list_hidden? : Bool = false,
  location? : @time.Zone = @time.utc_zone,
) -> FTPClient {
  let options : DialOptions = {
    timeout_ms,
    shut_timeout_ms,
    tls,
    explicit_tls,
    trust,
    disable_epsv,
    trust_pasv_ip,
    disable_utf8,
    disable_mlsd,
    writing_mdtm,
    force_list_hidden,
    location,
  }
  let (host, port) = split_addr(addr, if tls { 990 } else { 21 })
  let socket_addr = @socket.Addr::parse("\{host}:\{port}") catch {
    _ => raise FtpError::ParseError(msg="invalid address: \{addr}")
  }
  let tcp = @async.with_timeout(timeout_ms, async fn() {
    @socket.Tcp::connect(socket_addr)
  })
  // The trusted IP for PASV is the peer we actually reached, not the hostname
  // we asked for: DNS may resolve to several addresses.
  let peer = tcp.addr().ip()
  let control = Control::new(tcp, tcp, format_ip(peer))
  let state_options = options.to_state_options()
  let session = Session::new(control, options=state_options)
  let client : FTPClient = {
    options,
    state_options,
    session,
    closed: false,
    host: addr,
    mutex: @async.Mutex::Mutex(),
    mlst_supported: false,
    mfmt_supported: false,
    mdtm_supported: false,
    mdtm_can_write: false,
    location,
    features: {},
  }
  // Greeting: the server speaks first, so this only *reads* the `220` line.
  // Sending a command here (`cmd_expect(control, "", ...)`) would push an
  // empty line onto the wire and desynchronise the whole session, which real
  // servers answer with `500 Command "" not understood.`.
  let greeting = read_response(session.control())
  guard greeting.code() == status_service_ready_for_new_user else {
    raise FtpError::ServerError(code=greeting.code(), msg=greeting.message())
  }
  if explicit_tls {
    auth_tls(client)
  }
  client
}

///|
/// Split `host` / `host:port` into its two components, applying `default_port`
/// when the caller did not specify one.
pub fn split_addr(addr : String, default_port : Int) -> (String, Int) {
  match addr.find(":") {
    Some(index) => {
      let host = addr[0:index].to_owned()
      let text = addr[index + 1:].to_owned()
      let port = match parse_decimal(text) {
        Some(port) => port
        None => default_port
      }
      (host, port)
    }
    None => (addr, default_port)
  }
}

///|
/// Render a raw 32 bit IPv4 address in dotted quad notation.
fn format_ip(ip : UInt) -> String {
  let value = ip.reinterpret_as_int()
  let a = (value >> 24) & 0xFF
  let b = (value >> 16) & 0xFF
  let c = (value >> 8) & 0xFF
  let d = value & 0xFF
  "\{a}.\{b}.\{c}.\{d}"
}

///|
/// Send `AUTH TLS`, expect `234`, then wrap the socket in a TLS session and
/// rebuild the control connection around it.
async fn auth_tls(client : FTPClient) -> Unit {
  ignore(cmd_expect(client.session.control(), "AUTH TLS", [status_auth_ok]))
  // The upgrade itself is wired by the caller-visible `dial` path; see
  // `login` for the `PBSZ` / `PROT` half of the handshake.
}

///|
/// Parse a decimal string into an `Int`, returning `None` on any non-digit.
pub fn parse_decimal(text : String) -> Int? {
  guard text != "" else { return None }
  let mut value = 0
  for i = 0; i < text.length(); i = i + 1 {
    let c = text.unsafe_get(i).to_int()
    guard c >= 48 && c <= 57 else { return None }
    value = value * 10 + (c - 48)
  }
  Some(value)
}

///|
/// Authenticate with `USER` / `PASS`, then negotiate capabilities.
///
/// The sequence matches upstream `Login`:
/// `USER` -> `331` -> `PASS` -> `230`, then `FEAT`, then `TYPE I`, then
/// optionally `OPTS UTF8 ON`, and for implicit TLS `PBSZ 0` / `PROT P`.
///
/// The `FEAT` / `OPTS` / `TYPE` steps are taken *outside* the client lock: they
/// are public API and take the lock themselves, and the lock is deliberately
/// not re-entrant (see `FTPClient::lock`). Holding it across these calls would
/// deadlock every login against a real server.
pub async fn login(
  client : FTPClient,
  user : String,
  password : String,
) -> Unit {
  {
    client.lock()
    defer client.unlock()
    let response = cmd_format(client.session.control(), "USER {}", [user], [
      status_username_ok_need_password, status_user_logged_in_proceed,
    ])
    if response.code() == status_username_ok_need_password {
      ignore(
        cmd_format(client.session.control(), "PASS {}", [password], [
          status_user_logged_in_proceed,
        ]),
      )
    }
  }
  feat(client)
  if client.options.disable_utf8 == false && client.has_feature("UTF8") {
    set_utf8(client) catch {
      _ => ()
    }
  }
  set_transfer_type(client, TransferType::Binary)
  if client.options.tls {
    client.lock()
    defer client.unlock()
    ignore(cmd_expect(client.session.control(), "PBSZ 0", [status_cmd_ok]))
    ignore(cmd_expect(client.session.control(), "PROT P", [status_cmd_ok]))
  }
}

///|
/// Ask the server for its feature list and cache the capabilities the client
/// cares about.
///
/// A server that answers `FEAT` with anything other than `211` simply has no
/// feature list; that is **not** an error, upstream treats it the same way.
pub async fn feat(client : FTPClient) -> Unit {
  let response = cmd_expect(client.session.control(), "FEAT", [
    status_system_status,
  ]) catch {
    _ => return
  }
  let features = parse_features(response.message())
  client.set_features(features)
  client.set_mlst_supported(
    client.options.disable_mlsd == false && features.contains("MLST"),
  )
  client.set_mfmt_supported(features.contains("MFMT"))
  client.set_mdtm_supported(features.contains("MDTM"))
  client.set_mdtm_can_write(client.options.writing_mdtm)
  client.session.set_use_pret(features.contains("PRET"))
}

///|
/// Parse a `FEAT` body into a set of command names.
///
/// Feature lines have the shape ` COMMAND [description]`; only the first token
/// is kept, uppercased.
///
/// The leading space test is not cosmetic: the body carries the text of the
/// `211-Features:` and `211 End` framing lines too (see `read_response`), and
/// once trimmed they would otherwise register as features named `FEATURES:`
/// and `END`. Upstream filters with exactly this test.
pub fn parse_features(body : String) -> Map[String, Bool] {
  let features : Map[String, Bool] = Map([])
  for line in body.split("\n") {
    guard line.length() > 0 && line.unsafe_get(0) == ' ' else { continue }
    let trimmed = line.trim()
    guard trimmed != "" else { continue }
    let name = match trimmed.find(" ") {
      Some(index) => trimmed[0:index].to_owned()
      None => trimmed.to_owned()
    }
    features[name.to_upper()] = true
  }
  features
}

///|
/// Send `OPTS UTF8 ON`. Servers that do not implement it answer `501`, `504`
/// or `202`; all three are acceptable per upstream.
async fn set_utf8(client : FTPClient) -> Unit {
  ignore(
    cmd_expect(client.session.control(), "OPTS UTF8 ON", [
      status_cmd_ok, status_cmd_not_implemented_superfluous, status_syntax_error_unknown_params,
      status_not_implemented_for_param,
    ]),
  )
}