// 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.md` section 2.4 (API mapping).
///
/// 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~ : Int32 = default_dial_timeout_ms,
  shut_timeout_ms~ : Int32 = 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,
  debug_sink~ : &@io.Writer? = None,
  now_ms~ : () -> UInt64 = @env.now,
) -> 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,
    debug_sink,
    now_ms,
  }
  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 host_text = format_ip(peer)
  // Implicit TLS (port 990) is encrypted from the first byte: the `220`
  // greeting itself arrives inside the TLS session, so the handshake has to
  // happen before a single byte is read. Explicit TLS is the opposite and is
  // handled further down, after the plaintext greeting and `AUTH TLS`.
  //
  // `DialOptions.tls` used to only pick the default port, leaving the control
  // connection in the clear: against a real implicit server that reads the
  // TLS handshake as a malformed command line and answers `500`, and the
  // first `read_until` of the greeting blocks on bytes it can never parse.
  let control = if tls {
    let encrypted = @async.with_timeout(timeout_ms, async fn() {
      @tls.Tls::client(tcp, host=host_text, trust~)
    })
    Control::new(encrypted, encrypted, host_text, timeout_ms~)
  } else {
    Control::new(tcp, tcp, host_text, timeout_ms~)
  }
  // Attach the traffic log once the connection exists and before the greeting
  // is read, so the first record is what the server said first. Nothing is
  // installed when no sink was asked for.
  control.set_debug(
    match debug_sink {
      None => None
      Some(sink) => Some(DebugLog::new(sink))
    },
  )
  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, tcp)
  }
  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 : Int32) -> (String, Int32) {
  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 : UInt32) -> 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.
///
/// The order matters and is the whole point of the command: `234` is the
/// server's promise that *everything after this reply* is TLS. So the reply has
/// to be read off the plaintext connection first, and the client-side handshake
/// can only start once the `234` line has been consumed -- wrapping before
/// would negotiate TLS over a socket whose buffered `234` has not been read
/// yet, and the server would be waiting for a `ClientHello` while the client is
/// still waiting for a reply that is already in its own buffer.
///
/// The upgraded connection is built with `Tls::client`, which owns a *new*
/// reader/writer pair over the same socket (`Control` holds trait objects for
/// exactly this), and `Control::upgrade` swaps them in. Reusing `DialOptions.tls`
/// here would be wrong: that flag means implicit TLS on port 990, where the
/// greeting itself arrives encrypted and no `AUTH TLS` happens at all.
async fn auth_tls(client : FTPClient, socket : @socket.Tcp) -> Unit {
  let control = client.session.control()
  ignore(cmd_expect(control, "AUTH TLS", [status_auth_ok]))
  // `Tls::client` drives a full handshake and blocks on the peer's reply. A
  // peer that answered `234` but never speaks TLS (a broken proxy, a stub, or
  // a server that lied) would otherwise park this task forever -- the timeout
  // turns that into an ordinary failed dial.
  let deadline = client.options.timeout_ms
  let tls = @async.with_timeout(deadline, async fn() {
    @tls.Tls::client(socket, host=control.host(), trust=client.options.trust)
  }) catch {
    e =>
      raise FtpError::ServerError(
        code=status_service_unavailable,
        msg="TLS handshake after AUTH TLS failed: \{e}",
      )
  }
  control.upgrade(tls, tls)
}

///|
/// Parse a decimal string into an `Int`, returning `None` on any non-digit.
pub fn parse_decimal(text : String) -> Int32? {
  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` command for command:
/// `USER` -> `331` -> `PASS` -> `230`, then `FEAT`, then `TYPE I`, then
/// optionally `OPTS UTF8 ON`, then `PBSZ 0` / `PROT P` for either TLS flavour.
///
/// The `FEAT` / `TYPE` / `OPTS` 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)
  // `TYPE I` comes *before* `OPTS UTF8 ON`, which is upstream's order. The two
  // are independent (`TYPE` picks the data representation, `OPTS UTF8` the
  // filename encoding), so reversing them changes nothing a server can see --
  // but it would make this file's "matches upstream" claim false, and the
  // order is exactly what a reader compares against `ftp.go`.
  set_transfer_type(client, TransferType::Binary)
  if client.options.disable_utf8 == false && client.has_feature("UTF8") {
    set_utf8(client) catch {
      _ => ()
    }
  }
  // `PBSZ` / `PROT P` protect the *data* channel, and that is needed for both
  // FTPS flavours: `AUTH TLS` only upgrades the control channel, so an
  // explicit-TLS session that stops there answers every transfer with
  // `550 SSL/TLS required on the data channel.` from a server configured the
  // way RFC 4217 intends (vsftpd's `force_local_data_ssl`). Implicit TLS needs
  // it for the same reason after the greeting: the data connection is still a
  // fresh plaintext socket.
  //
  // Upstream keys this on `dialOpts.TLS` alone, which is why its implicit-only
  // handling looked complete; the explicit path was never wired end to end.
  if client.options.tls || client.options.explicit_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,
    ]),
  )
}