///|
/// 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.
///
/// 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(bridge : BridgeState, pty : @pty.Pty) -> 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) {
    group.spawn_bg(async fn() {
      let exit_code = @process.wait_pid(pid)
      @log.info("pty_reader: child exited(\{exit_code}), pid=\{pid}")
      bridge.pty = None
      bridge.shell_exited.val = true
      raise ShellExited
    })
    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])
        let created = @async.now()
        let entry = bridge.replay.append(created, bytes)
        let responses = @terminal.pty_to_host(bytes, bridge.terminal)
        for resp in responses {
          pty.write(resp) catch {
            err if @async.is_being_cancelled() => raise err
            err =>
              @log.error(
                "[pty.host_response.write.fail] bytes=\{resp.length()} error=\{@debug.to_string(err)}",
              )
          }
        }
        // Remote broadcast — only when a relay session is connected.
        if bridge.current.val is Some(session) {
          let data = @base64.encode(bytes[:])
          for client_id, _ in session.clients {
            let msg : @proto.Message = Term(
              Data(client_id~, id=entry.id, created=Some(created), data~),
            )
            session.send_queue.put(msg.to_json().stringify()) catch {
              err if @async.is_being_cancelled() => raise err
              err =>
                @log.error(
                  "[relay.enqueue.fail] source=\"pty_broadcast\" client_id=\{client_id} id=\{entry.id} error=\{@debug.to_string(err)}",
                )
            }
          }
        }
      } else {
        @log.info("pty_reader: read returned \{n} (EOF), pid=\{pid}")
        bridge.pty = None
        bridge.shell_exited.val = true
        raise ShellExited
      }
    }
  })
}

///|
/// Read from local stdin, let the terminal backend filter each chunk
/// (consuming any host-side control input it owns and returning the
/// rest), then forward the filtered bytes 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(bridge : BridgeState) -> Unit {
  let buf : FixedArray[Byte] = FixedArray::make(4096, b'\x00')
  for ;; {
    let n = @async/stdio.stdin.read(buf)
    if n <= 0 {
      break
    }
    let chunk = Bytes::from_array(buf[:n])
    let forward = @terminal.host_to_pty(chunk, bridge.terminal)
    if forward.length() > 0 && bridge.pty is Some(pty) {
      pty.write(forward) catch {
        err if @async.is_being_cancelled() => raise err
        _ => @log.error("local stdin -> pty write failed")
      }
    }
  }
  @log.info("local_stdin_task: exited")
}

///|
/// 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",
      )
  }
}

///|
/// Bridge setup: now that the relay session is established, save
/// termios, set raw mode, redirect verbose logs to the file sink, spawn
/// the PTY-backed child, then run the active session and reconnect
/// loop.
async fn run_bridge(
  config : ConnectConfig,
  credential : CredentialStore,
  child_argv : Array[String],
  first_ws : @ws.Conn,
) -> Unit {
  let in_fd = @async/stdio.stdin.fd()
  let out_fd = @async/stdio.stdout.fd()
  let saved = @tty.save_termios(in_fd, out_fd)
  @tty.set_raw(saved)
  maybe_redirect_log_to_file()
  @tty.arm_exit_restore(saved)
  defer (@tty.restore(saved) catch {
    @tty.TermiosSetFailed(errno~) =>
      @log.warn("tty restore failed: \{errno.code()} (\{errno.strerror()})")
    error => @log.warn("tty restore failed: \{error.to_repr()}")
  })
  let terminal = @terminal.new() catch {
    err => {
      @log.error("terminal init failed: \{err}")
      return
    }
  }
  // Let the backend write any one-shot host-state setup it needs
  // before the bridge starts forwarding bytes. The defer runs before
  // the tty restore defer thanks to LIFO ordering, so we're still in
  // raw mode when the teardown bytes are emitted.
  @terminal.host_setup(terminal)
  defer @terminal.host_teardown(terminal)
  let bridge = BridgeState::new(terminal)
  // The relay has already accepted the session, so it is now safe to
  // take over the local tty and spawn the PTY-backed child. The PTY is
  // fixed at 80x24 for the life of the session — no SIGWINCH handling,
  // no client-driven resize. Remote clients render this grid at
  // whatever font size / CSS scale fits their viewport.
  let pty = @pty.Pty::spawn(child_argv, cols=80, rows=24) catch {
    @pty.SpawnFailed(msg) => {
      @log.error("Failed to spawn child: \{msg}")
      return
    }
    err => {
      @log.error("Failed to spawn child: \{err.to_repr()}")
      return
    }
  }
  bridge.pty = Some(pty)
  defer {
    bridge.pty = None
    pty.close()
  }
  @log.info("spawned child pid=\{pty.pid()} size=80x24")
  @async.with_task_group(async fn(group) {
    group.spawn_bg(no_wait=true, async fn() { local_stdin_task(bridge) })
    // pty_reader runs persistently — tees to local stdout always,
    // broadcasts to remote clients only when a relay session is
    // active. Spawning it here (instead of inside run_session) means
    // the local terminal works the moment tun-server starts,
    // regardless of relay state.
    group.spawn_bg(no_wait=true, async fn() { pty_reader(bridge, pty) })
    run_retry_loop(config, credential, bridge, first_ws~)
  }) catch {
    ShellExited => @log.info("shell exited, server shutting down")
    err => raise err
  }
}