///|
/// The real-filesystem `FileSystem` backend, and the socket backend a session
/// runs on.
///
/// Both live here, apart from the server logic, so the state machine file stays
/// free of platform imports and still runs on any backend. This file needs a
/// host filesystem and UDP sockets; it is the one place in the module that
/// does.

///|
/// A `FileSystem` backed by the host's filesystem.
///
/// Every failure is translated into the TFTP error code that describes it
/// (RFC 1350, section 5) *here*, at the one boundary that knows what a
/// filesystem error is, so the session above never sees an OS error.
pub struct HostFileSystem {
  /// Keeps the type non-empty so it reads as a distinct handle, and keeps the
  /// backend free to grow without changing callers.
  root : String
}

///|
/// A filesystem view of the host.
pub fn HostFileSystem::new() -> HostFileSystem {
  { root: "/", }
}

///|
/// The `ServerError` a failed host operation becomes.
///
/// A host that is out of space is rare in a test but real in the field, so it
/// gets its own code (ERROR 3) rather than being lumped into "access denied".
///
/// A path that names a directory is a refusal of exactly the kind ERROR 2
/// means (RFC 1350, section 5), and the platforms word that refusal
/// differently: Linux answers `Is a directory`, some builds a bare `EISDIR`,
/// and a directory whose child cannot be written answers `Not a directory`.
/// All of them are matched here so the code is the same everywhere.
///
/// **A missing path is checked first, and deliberately so.** `ENOENT` reads
/// "No such file or directory", which contains the word the directory branch
/// looks for; without the ordering below it would be reported as a refusal,
/// and a plain 404 would arrive at the peer as ERROR 2. Anything left over is
/// "we cannot give you this file", which is what ERROR 1 means.
fn host_error_message(error : Error, operation : String) -> ServerError {
  let text = error.to_string()
  if text.contains("No space") ||
    text.contains("no space") ||
    text.contains("ENOSPC") {
    DiskFull(operation)
  } else if text.contains("ENOENT") ||
    text.contains("No such file") ||
    text.contains("no such file") ||
    text.contains("Not found") ||
    text.contains("not found") {
    FileNotFound(operation)
  } else if text.contains("EISDIR") ||
    text.contains("Is a directory") ||
    text.contains("is a directory") ||
    text.contains("Not a directory") ||
    text.contains("not a directory") ||
    text.contains("ENOTDIR") {
    AccessViolation(operation)
  } else if text.contains("Permission") ||
    text.contains("EACCES") ||
    text.contains("permission") {
    AccessViolation(operation)
  } else {
    FileNotFound(operation)
  }
}

///|
pub extend HostFileSystem with FileSystem::{read, write, exists, is_directory}

///|
pub impl FileSystem for HostFileSystem with fn read(self, path) {
  ignore(self)
  try {
    let data : &@io.Data = @fs.read_file(path)
    Ok(data.binary())
  } catch {
    error => Err(host_error_message(error, "read \{path}"))
  }
}

///|
pub impl FileSystem for HostFileSystem with fn write(self, path, payload) {
  ignore(self)
  try {
    @fs.write_file(path, payload)
    Ok(())
  } catch {
    error => Err(host_error_message(error, "write \{path}"))
  }
}

///|
pub impl FileSystem for HostFileSystem with fn exists(self, path) {
  ignore(self)
  @fs.exists(path)
}

///|
pub impl FileSystem for HostFileSystem with fn is_directory(self, path) {
  ignore(self)
  // A path that is not there at all is *not* a directory: `read` owes the peer
  // ERROR 1, and calling a missing name a refusal would turn a plain 404 into
  // ERROR 2. A stat that fails for any reason is therefore the `false` branch,
  // and a symlink counts because it is what the name resolves to.
  is_directory_kind(@fs.kind(path)) catch {
    _ => false
  }
}

///|
/// Whether a host `FileKind` names a directory.
///
/// A separate function because the answer is a two-line rule about one value,
/// and keeping it out of the `impl` lets the rule be asserted directly rather
/// than through a stat that may not succeed the way a test wants on every host.
fn is_directory_kind(kind : @fs.FileKind) -> Bool {
  match kind {
    @fs.FileKind::Directory | @fs.FileKind::SymLink => true
    _ => false
  }
}

///|
/// A session transport over a real UDP socket.
///
/// The socket is already bound to the session's TID, so `send`/`recv` only ever
/// take the peer's address: the TID rule (RFC 1350, section 4) is enforced by
/// the kernel's `connect`, which is exactly where a packet from a stranger
/// should be dropped.
pub struct SocketSession {
  socket : @socket.UdpClient
  tid : String
}

///|
/// Binds a session socket to `local` and locks it onto `peer`.
pub async fn SocketSession::new(
  bind_to : String,
  peer : String,
) -> SocketSession {
  let socket = @socket.UdpClient(@socket.Addr::parse(bind_to))
  let peer_addr = @socket.Addr::parse(peer)
  socket.connect(peer_addr)
  { socket, tid: bind_to, }
}

///|
/// The TID this session is bound to.
pub fn SocketSession::tid(self : SocketSession) -> String {
  self.tid
}

///|
/// Releases the TID's port.
pub fn SocketSession::close(self : SocketSession) -> Unit {
  self.socket.close()
}

///|
/// Sends one datagram to the peer this socket was connected to.
pub async fn SocketSession::send(self : SocketSession, payload : Bytes) -> Unit {
  self.socket.send(payload)
}

///|
/// Waits up to `timeout_ms` for a datagram, reporting `Recv::Timeout` if none
/// arrived.
///
/// This is where the retransmission timer lives: a real client that goes quiet
/// has to make the session time out, and the state machine above treats that
/// exactly like a lost packet.
pub async fn SocketSession::recv(
  self : SocketSession,
  timeout_ms : Int32,
) -> Recv {
  let buffer = FixedArray::make(65536, b'\x00')
  match
    @async.with_timeout_opt(timeout_ms, () => {
      let length = self.socket.recv(buffer)
      buffer.unsafe_reinterpret_as_bytes()[:length].to_owned()
    }) {
    Some(payload) =>
      Recv::Packet(Datagram::new(payload, self.socket.addr.to_string()))
    None => Recv::Timeout
  }
}

///|
pub extend SocketSession with Transport::{local_peer}

///|
/// A `SocketSession` is also a `Transport`: the peer is fixed at construction,
/// so the peer argument of `send` / `recv` is redundant but ignored. This is
/// what lets the session state machines above run unchanged on a real socket.
///
/// The bodies reach for the socket directly rather than calling `self.send` /
/// `self.recv`, which name the *inherent* single-argument helpers and would
/// not compile here.
pub impl Transport for SocketSession with fn local_peer(self) {
  self.tid
}

///|
pub impl Transport for SocketSession with fn send(self, payload, _peer) {
  self.socket.send(payload)
}

///|
pub impl Transport for SocketSession with fn recv(self, _peer, timeout_ms) {
  let buffer = FixedArray::make(65536, b'\x00')
  match
    @async.with_timeout_opt(timeout_ms, () => {
      let length = self.socket.recv(buffer)
      buffer.unsafe_reinterpret_as_bytes()[:length].to_owned()
    }) {
    Some(payload) =>
      Recv::Packet(Datagram::new(payload, self.socket.addr.to_string()))
    None => Recv::Timeout
  }
}