///|
#warnings("-alert_internal")
fn uuid_v4() -> @uuid.UUID raise {
  let bytes = @tls.rand_bytes(16)
  @uuid.from_bytes(bytes).as_version(@uuid.V4)
}

///|
fn build_command() -> @argparse.Command {
  @argparse.Command::new(
    "tun-server",
    about="TUN server that connects to a relay via WebSocket",
    options=[
      @argparse.OptionArg::new(
        "relay",
        short='r',
        long="relay",
        about="Relay host (optionally with :port, e.g. 127.0.0.1:8080)",
        default_values=["tun-poc.moonbitlang.com"],
      ),
    ],
    positionals=[
      @argparse.PositionArg::new(
        "child",
        num_args=@argparse.ValueRange::new(lower=0),
        allow_hyphen_values=true,
        about="Program to spawn (defaults to $SHELL or /bin/sh on Unix, %COMSPEC% or cmd.exe on Windows). Use `--` to pass flags to the program.",
      ),
    ],
  )
}

///|
/// True for hosts that should use plaintext `ws://` / `http://` instead of
/// TLS. Mirrors `isLocalHost` in `android/.../TerminalScreen.kt` so both
/// clients classify the same host identically.
fn is_local_host(host : String) -> Bool {
  host.has_prefix("127.") ||
  host.has_prefix("10.") ||
  host.has_prefix("192.168.") ||
  host.has_prefix("localhost")
}

///|
/// Build the relay WebSocket URL from a bare host (optionally with
/// `:port`, e.g. `127.0.0.1:8080`). Picks `ws://` for local-style hosts
/// and `wss://` otherwise, appends the fixed `/server` suffix, and parses
/// the result so downstream code gets a structured `@url.Url` and we fail
/// fast on a malformed `--relay` argument.
fn relay_url_from_host(host : String) -> @url.Url raise {
  let scheme = if is_local_host(host) { "ws" } else { "wss" }
  @url.Url::parse("\{scheme}://\{host}/server")
}

///|
/// Raised by `pty_reader` when the local shell exits. Propagates up through
/// the outer `with_task_group` in `run_bridge`, cancelling all bridge tasks
/// and the retry loop so `main` returns cleanly.
priv suberror ShellExited derive(Debug)

///|
/// Persistent PTY reader. Spawned once at startup and lives for the entire
/// session, independent of the relay. It tees output to the local terminal
/// **regardless of relay state** and broadcasts to remote clients only when
/// a relay session is active. This is what makes the local shell usable
/// even when the relay is down or unreachable.
///
/// On EOF (local shell exited / `exit` / `Ctrl-D`), it raises `ShellExited`,
/// which propagates out through the outer task group and shuts the server
/// down cleanly.
async fn pty_reader(
  pty : @pty.Pty,
  pty_ref : Ref[@pty.Pty?],
  clients : Map[String, Unit],
  send_queue_ref : Ref[@aqueue.Queue[String]?],
  shell_exited_ref : Ref[Bool],
  log : Array[(Int64, Bytes)],
  next_id : Ref[Int64],
) -> Unit {
  @log.info("pty_reader: starting, pid=\{pty.pid()}")
  let pid = pty.pid()
  // Use a task group so that when wait_pid fires (child exited), the
  // blocked read loop is cancelled — ConPTY on Windows does not deliver
  // EOF on the output pipe until ClosePseudoConsole, so we cannot rely
  // on read returning 0 to detect child exit.
  @async.with_task_group(async fn(group) {
    // Background task: wait for the child process to exit.
    group.spawn_bg(async fn() {
      let exit_code = @process.wait_pid(pid)
      @log.info("pty_reader: child exited(\{exit_code}), pid=\{pid}")
      pty_ref.val = None
      shell_exited_ref.val = true
      raise ShellExited
    })
    // Foreground: read loop.
    let reader = pty.reader()
    let buf : FixedArray[Byte] = FixedArray::make(4096, b'\x00')
    for ;; {
      let n = reader.read(buf)
      @log.info("pty_reader: read returned n=\{n}")
      if n > 0 {
        let bytes = Bytes::from_array(buf[:n])
        // Append to the id-indexed log for delta replay on reconnect.
        // Each PTY chunk gets a dense id starting at 1L. Callers that
        // reconnect send their highest-seen id as Connect.last_seen_id;
        // NewClient handling slices the tail of this log to replay.
        next_id.val = next_id.val + 1L
        let id = next_id.val
        log.push((id, bytes))
        // Local tee — always, even when no relay session is active.
        let written = @stdio.fwrite(bytes, 1, n.to_uint64(), @stdio.stdout)
        @log.info("pty_reader: fwrite n=\{n} -> written=\{written}")
        ignore(try? @stdio.fflush(@stdio.stdout))
        // Remote broadcast — only when a relay session is connected.
        if send_queue_ref.val is Some(send_queue) {
          let data = @base64.encode(bytes[:])
          for client_id, _ in clients {
            let msg : @proto.Message = Term(Data(client_id~, id~, data~))
            send_queue.put(msg.to_json().stringify()) catch {
              err if @async.is_being_cancelled() => raise err
              _ => ()
            }
          }
        }
      } else {
        // EOF on Unix — child exited and pipe closed.
        @log.info("pty_reader: read returned \{n} (EOF), pid=\{pid}")
        pty_ref.val = None
        shell_exited_ref.val = true
        raise ShellExited
      }
    }
  })
}

///|
/// Read from local stdin and forward every chunk to the PTY master. Exits
/// when stdin EOFs or when the task group cancels this task. Uses
/// `@async/stdio.stdin` directly so the same code works on Unix (epoll on
/// fd 0) and Windows (worker-thread blocking read on the console HANDLE).
async fn local_stdin_task(pty_ref : Ref[@pty.Pty?]) -> Unit {
  let buf : FixedArray[Byte] = FixedArray::make(4096, b'\x00')
  for ;; {
    let n = @async/stdio.stdin.read(buf)
    if n <= 0 {
      break
    }
    if pty_ref.val is Some(pty) {
      pty.write(Bytes::from_array(buf[:n])) catch {
        err if @async.is_being_cancelled() => raise err
        _ => @log.error("local stdin -> pty write failed")
      }
    }
  }
  @log.info("local_stdin_task: exited")
}

///|
/// Connect to the relay and send the initial Register message.
///
/// Returns an open WebSocket only after the relay has accepted the
/// registration with `Registered(...)`. Any other response is treated as an
/// error so the caller can retry or fail before touching tty/pty state.
async fn connect_and_register(
  url : @url.Url,
  headers : Map[String, String],
  task_uuid : @uuid.UUID,
  hostname : String,
  cwd : String,
) -> @ws.Conn {
  @log.info("Connecting to relay: \{url}")
  let ws = @ws.Conn::connect(url.to_string(), headers~)
  let register_msg : @proto.Message = Sys(Register(task_uuid~, hostname~, cwd~))
  ws.send_text(register_msg.to_json().stringify()) catch {
    err => {
      ws.close()
      raise err
    }
  }
  @log.info("Sent: register")
  let response = ws.recv().read_all().text() catch {
      err => {
        ws.close()
        raise err
      }
    }
  @log.info("Received: \{response}")
  let msg : @proto.Message = @json.from_json(@json.parse(response)) catch {
    err => {
      ws.close()
      raise err
    }
  }
  match msg {
    Sys(Registered(task_uuid=echoed_uuid)) => {
      if echoed_uuid != task_uuid {
        ws.close()
        fail(
          "Registered echoed wrong uuid: got \{echoed_uuid}, expected \{task_uuid}",
        )
      }
      @log.info("Registered task_uuid=\{task_uuid}")
      ws
    }
    _ => {
      ws.close()
      fail("unexpected register response: \{@debug.to_string(msg)}")
    }
  }
}

///|
fn is_auth_handshake_rejection(code : Int) -> Bool {
  code == 401 || code == 403
}

///|
/// Retry relay connection / registration until a session is established or a
/// fatal error occurs. This runs before tty/pty setup so early failures stay
/// visible on the terminal.
async fn connect_retry_loop(
  url : @url.Url,
  headers : Map[String, String],
  task_uuid : @uuid.UUID,
  hostname : String,
  cwd : String,
) -> @ws.Conn? {
  Some(
    @async.retry(
      ExponentialDelay(initial=1000, factor=2.0, maximum=5000),
      fatal_error=fn(err) {
        match err {
          @ws.HandshakeRejected(_, response) =>
            is_auth_handshake_rejection(response.code)
          _ => false
        }
      },
      () => connect_and_register(url, headers, task_uuid, hostname, cwd),
    ),
  ) catch {
    err if @async.is_being_cancelled() => raise err
    @ws.HandshakeRejected(_, response) if is_auth_handshake_rejection(
        response.code,
      ) => {
      remove_stale_credential(credential_path(url))
      @log.error(
        "relay rejected credential (this server may have been revoked); " +
        "removed stale credential, re-run tun-server to re-pair",
      )
      None
    }
    @ws.HandshakeRejected(msg, response) => {
      @log.error(
        "Fatal: relay handshake rejected: \{response.code} \{response.reason}: \{msg}",
      )
      None
    }
    @ws.InvalidHandshake(msg) => {
      @log.error("Fatal: relay handshake failed: \{msg}")
      None
    }
    err => {
      @log.error("Fatal: \{err}")
      None
    }
  }
}

///|
/// Run the relay retry loop. The shared `clients` / `send_queue_ref` are
/// owned by the caller and also read by `pty_reader` so the local tty
/// bridge keeps working across reconnects.
async fn run_retry_loop(
  url : @url.Url,
  headers : Map[String, String],
  task_uuid : @uuid.UUID,
  hostname : String,
  cwd : String,
  pty_ref : Ref[@pty.Pty?],
  clients : Map[String, Unit],
  send_queue_ref : Ref[@aqueue.Queue[String]?],
  shell_exited_ref : Ref[Bool],
  log : Array[(Int64, Bytes)],
  first_ws? : @ws.Conn,
) -> Unit {
  match first_ws {
    Some(ws) =>
      run_session(ws, pty_ref, clients, send_queue_ref, shell_exited_ref, log) catch {
        err if @async.is_being_cancelled() => raise err
        err => @log.info("relay session lost, will reconnect: \{err}")
      }
    None => ()
  }
  @async.retry(
    ExponentialDelay(initial=1000, factor=2.0, maximum=5000),
    fatal_error=fn(err) {
      match err {
        @ws.HandshakeRejected(_, response) =>
          is_auth_handshake_rejection(response.code)
        _ => false
      }
    },
    () => {
      let ws = connect_and_register(url, headers, task_uuid, hostname, cwd)
      run_session(ws, pty_ref, clients, send_queue_ref, shell_exited_ref, log)
    },
  ) catch {
    err if @async.is_being_cancelled() => raise err
    @ws.HandshakeRejected(_, response) if is_auth_handshake_rejection(
        response.code,
      ) => {
      remove_stale_credential(credential_path(url))
      @log.error(
        "relay rejected credential (this server may have been revoked); " +
        "removed stale credential, re-run tun-server to re-pair",
      )
    }
    @ws.HandshakeRejected(msg, response) =>
      @log.error(
        "Fatal: relay handshake rejected: \{response.code} \{response.reason}: \{msg}",
      )
    @ws.InvalidHandshake(msg) =>
      @log.error("Fatal: relay handshake failed: \{msg}")
    err => @log.error("Fatal: \{err}")
  }
}

///|
/// A single registered relay session. The caller owns connection setup and
/// passes an already-accepted WebSocket in; this function only runs the
/// session loop and raises on connection loss so the caller can retry.
async fn run_session(
  ws : @ws.Conn,
  pty_ref : Ref[@pty.Pty?],
  clients : Map[String, Unit],
  send_queue_ref : Ref[@aqueue.Queue[String]?],
  shell_exited_ref : Ref[Bool],
  log : Array[(Int64, Bytes)],
) -> Unit {
  defer ws.close()
  // Event loop with shared PTY
  @log.info("Entering message loop...")
  @async.with_task_group(async fn(group) {
    // Single send queue serializes all outgoing WebSocket messages.
    // Publish it via send_queue_ref so the persistent pty_reader can
    // broadcast pty bytes to remote clients during this session. The defer
    // clears it on disconnect so the reader knows there is no active session
    // and stops broadcasting.
    let send_queue : @aqueue.Queue[String] = @aqueue.Queue::new(kind=Unbounded)
    send_queue_ref.val = Some(send_queue)
    // Graceful-shutdown defer. pty_reader sets `shell_exited_ref.val = true`
    // when the local shell EOFs; by the time we reach here the surrounding
    // task group is already being cancelled (pty_reader raised ShellExited
    // up at run_bridge's outer group), but the ws is still open — outer
    // `defer ws.close()` only runs after run_session returns. We
    // `protect_from_cancel` the send so it survives the cancellation.
    group.add_defer(async fn() {
      if shell_exited_ref.val {
        @async.protect_from_cancel(async fn() {
          let exit_msg : @proto.Message = Sys(TaskExited(exit_code=0))
          ws.send_text(exit_msg.to_json().stringify()) catch {
            _ => ()
          }
        })
      }
      send_queue_ref.val = None
      clients.clear()
    })
    group.spawn_bg(async fn() {
      for ;; {
        let msg = send_queue.get() catch {
          err if @async.is_being_cancelled() => raise err
          _ => break
        }
        ws.send_text(msg) catch {
          err if @async.is_being_cancelled() => raise err
          _ => ()
        }
      }
    })
    for ;; {
      let text = ws.recv().read_all().text()
      let incoming : @proto.Message = @json.from_json(@json.parse(text)) catch {
        err => {
          @log.error(
            "Failed to decode incoming message: raw=\{@debug.to_string(text)}, error=\{@debug.to_string(err)}",
          )
          continue
        }
      }
      handle_message(incoming, pty_ref, clients, send_queue, log) catch {
        err if @async.is_being_cancelled() => raise err
        err =>
          @log.error(
            "Error processing message: message=\{incoming.to_log_string()}, raw=\{@debug.to_string(text)}, error=\{@debug.to_string(err)}",
          )
      }
    }
  })
}

///|
/// Move verbose runtime logs to the default file sink once the local tty has
/// entered raw mode so PTY traffic owns the terminal. Failures here are
/// non-fatal and continue on the current sink.
async fn maybe_redirect_log_to_file() -> Unit {
  let log_path = @log.default_log_path()
  try {
    @log.ensure_log_dir_for(log_path)
    @log.set_output_path(log_path, append=true)
  } catch {
    err if @async.is_being_cancelled() => raise err
    err =>
      @log.warn(
        "Failed to prepare log file: \{@debug.to_string(err)}; continuing with current log sink",
      )
  }
}

///|
/// Path to the long-lived credential file that holds the `tp_*` token
/// minted by the device-code flow. `$TUN_CREDENTIAL_PATH` overrides the
/// default for containers / systemd `LoadCredential=`.
///
/// The default path is scoped per relay (`//credential.key`)
/// so a dev relay at `localhost:12380` and production at
/// `tun-poc.moonbitlang.com` keep independent tokens. `:` in the host
/// segment is rewritten to `_` so the subdir name is also valid on Windows.
fn credential_path(relay_url : @url.Url) -> String {
  match @env.get_env_var("TUN_CREDENTIAL_PATH") {
    Some(path) if path != "" => path
    _ => {
      let host = relay_url.host().replace(old=":", new="_")
      "\{@xdg.data_dir()}/\{host}/credential.key"
    }
  }
}

///|
/// Convert a relay WebSocket URL (`ws://…/server` or `wss://…/server`) into
/// the HTTP base URL used for device-flow endpoints (`http://…/` /
/// `https://…/`). Re-parses `ws_url.href()` so the returned URL is a fresh
/// copy — the caller's `ws_url` is never mutated. The pathname is reset to
/// `/` because device-flow endpoints live at `/device/*`, not under
/// `/server`.
fn http_base_from_ws(ws_url : @url.Url) -> @url.Url raise {
  let http_base = @url.Url::parse(ws_url.href())
  let scheme = if ws_url.protocol() == "wss:" { "https:" } else { "http:" }
  http_base.set_protocol(scheme)
  http_base.set_pathname("/")
  http_base
}

///|
/// Read the credential file if it exists. Returns None on missing file or
/// any read error.
async fn read_credential_file(path : String) -> String? {
  let exists = @async/fs.exists(path) catch {
    err if @async.is_being_cancelled() => raise err
    _ => false
  }
  if !exists {
    return None
  }
  let data = @async/fs.read_file(path) catch {
    err if @async.is_being_cancelled() => raise err
    _ => return None
  }
  let text = data.text().trim(char_set=" \t\r\n").to_string()
  if text == "" {
    None
  } else {
    Some(text)
  }
}

///|
/// Recursively ensure a directory exists. Works around a bug in
/// `@async/fs.mkdir(recursive=true)` on Windows where `EEXIST` is not
/// handled during parent-directory recursion.
async fn ensure_dir(path : String) -> Unit {
  @log.info("ensure_dir: \{path}")
  @async/fs.mkdir(path, permission=0o700) catch {
    err if @async.is_being_cancelled() => raise err
    @os_error.OSError(_) as e if e.is_EEXIST() =>
      @log.info("ensure_dir: \{path} already exists")
    @os_error.OSError(_) as e if e.is_ENOENT() => {
      @log.info("ensure_dir: \{path} ENOENT, creating parent")
      let parent = match path.rev_find("/") {
        Some(i) => path.view(start_offset=0, end_offset=i).to_string()
        None => path
      }
      if parent == path {
        raise e
      }
      ensure_dir(parent)
      @async/fs.mkdir(path, permission=0o700) catch {
        err if @async.is_being_cancelled() => raise err
        @os_error.OSError(_) as e if e.is_EEXIST() => ()
        e => raise e
      }
    }
    e => {
      @log.error("ensure_dir: \{path} unexpected error: \{e}")
      raise e
    }
  }
}

///|
/// Best-effort removal of a stale credential file after the relay rejects
/// it. Missing file / permission errors are swallowed — the caller has
/// already logged the rejection and the user will simply be prompted for
/// a fresh device-flow pairing on next run.
async fn remove_stale_credential(path : String) -> Unit {
  @async/fs.remove(path) catch {
    err if @async.is_being_cancelled() => raise err
    _ => ()
  }
}

///|
/// Write the credential file with mode 0600. `@async/fs` doesn't expose
/// rename(2), so we write directly — the file is small and torn writes
/// would just be rejected by the relay on next connect, triggering a
/// re-pair on the next run.
async fn write_credential_file(path : String, token : String) -> Unit {
  // Make sure the parent directory exists (first run after install may
  // not have $XDG_DATA_HOME/tun-poc yet).
  // Note: we use rfind("/") instead of @path.Path::dirname() because
  // the win32 dirname only recognises "\", not "/".
  let parent = match path.rev_find("/") {
    Some(i) => path.view(start_offset=0, end_offset=i).to_string()
    None => path
  }
  ensure_dir(parent)
  @async/fs.write_file(path, @utf8.encode(token[:]), create=0o600)
  // Explicit chmod in case the file already existed with wider perms. POSIX
  // only — Windows file ACLs work differently and `@async/fs.chmod` isn't
  // exposed there. The `create=0o600` above already covers the common path
  // (file did not previously exist).
  chmod_credential_file(path)
}

///|
#cfg(not(platform="windows"))
async fn chmod_credential_file(path : String) -> Unit {
  @async/fs.chmod(path, 0o600) catch {
    err if @async.is_being_cancelled() => raise err
    _ => ()
  }
}

///|
#cfg(platform="windows")
fn chmod_credential_file(path : String) -> Unit {
  ignore(path)
}

///|
/// Raised by `getcwd` when `@env.current_dir()` returns `None`. In practice
/// a process without a valid cwd cannot run, so this is effectively
/// unreachable — the suberror exists so callers can depend on a `String`
/// rather than `String?`.
pub suberror GetcwdFailed derive(Debug)

///|
/// Returns the server process's current working directory. Thin wrapper
/// around `@env.current_dir()`, which reads the kernel's cwd via POSIX
/// `getcwd(2)`. The wrapper exists so callers can depend on a `String`
/// rather than `String?`.
pub fn getcwd() -> String raise GetcwdFailed {
  match @env.current_dir() {
    Some(p) => p
    None => raise GetcwdFailed
  }
}

///|
/// POST a JSON body to `url` and parse the response as JSON. Raises on
/// non-2xx status.
async fn http_post_json(url : String, body : Json) -> Json {
  let body_text = body.stringify()
  let headers : Map[String, String] = { "Content-Type": "application/json" }
  let (response, data) = @http.post(url, body_text, headers~)
  let text = data.text()
  if response.code < 200 || response.code >= 300 {
    fail("HTTP \{response.code}: \{text}")
  }
  @json.parse(text)
}

///|
/// Run the device-code flow: ask the relay for a pairing code, print the
/// verification URL (OSC 8 clickable if the terminal supports it), and
/// poll until the user approves or the code expires.
///
/// Returns the raw `tp_*` access token on success; raises on
/// denial/expiry/network error.
async fn run_device_flow(http_base : @url.Url) -> String {
  let auth_url = @url.Url::parse("/device/authorization", base=http_base).to_string()
  let token_url = @url.Url::parse("/device/token", base=http_base).to_string()
  let hostname = @os.gethostname()
  @log.info("device flow: requesting pairing code hostname=\{hostname}")
  let auth_req : @proto.DeviceAuthorizationRequest = { hostname, }
  let auth_resp_json = http_post_json(auth_url, auth_req.to_json()) catch {
    err if @async.is_being_cancelled() => raise err
    e => fail("device /authorization failed: \{e}")
  }
  let auth_resp : @proto.DeviceAuthorizationResponse = @json.from_json(
    auth_resp_json,
  ) catch {
    e => fail("device /authorization: malformed response: \{e}")
  }
  let device_code = auth_resp.device_code
  let user_code = auth_resp.user_code
  let verification_uri_complete = auth_resp.verification_uri_complete.unwrap_or(
    auth_resp.verification_uri + "?code=" + user_code,
  )
  let expires_in = auth_resp.expires_in
  let mut interval_ms = auth_resp.interval * 1000
  if interval_ms < 1000 {
    interval_ms = 1000
  }
  // OSC 8 hyperlink escape: ESC ] 8 ;;  ESC \