// Host filesystem browsing for the repository picker: listing absolute host
// directories and switching the served workspace root. These are host-level
// packets handled outside the per-session policy server. What they may reach
// is bounded by the server's `HostBrowseScope`: unrestricted browsing is
// reserved for loopback listeners unless the operator grants an explicit
// `--browse-root` scope, so an unauthenticated LAN-bound server cannot be
// re-rooted onto arbitrary readable directories.

///|
/// Where host browsing and workspace switching may reach.
priv enum HostBrowseScope {
  /// The whole host filesystem — the loopback default.
  BrowseHost
  /// Only the given realpathed directory and its descendants.
  BrowseWithin(String)
  /// Browsing rejected entirely — the non-loopback default.
  BrowseDisabled
}

///|
fn HostBrowseScope::allows(self : HostBrowseScope, real : String) -> Bool {
  match self {
    BrowseHost => true
    BrowseWithin(scope) =>
      scope == "/" || real == scope || real.has_prefix("\{scope}/")
    BrowseDisabled => false
  }
}

///|
/// The parent offered in listings; `None` at the scope boundary so clients
/// cannot navigate above it.
fn HostBrowseScope::visible_parent(
  self : HostBrowseScope,
  real : String,
) -> String? {
  match self {
    BrowseWithin(scope) if real == scope => None
    _ => parent_host_path(real)
  }
}

///|
fn browse_disabled_error(request_id : String) -> @remote_protocol.ServerPacket {
  host_browse_error(
    request_id,
    InvalidUri,
    "Workspace browsing is disabled on this server. Restart it with --browse-root to allow switching repositories.",
  )
}

///|
/// One resolved host directory level or a filesystem-shaped failure.
priv enum HostDirectoryListResult {
  HostDirectoryListOk(@remote_protocol.HostDirectoryListing)
  HostDirectoryListError(@workspace.FileSystemProviderErrorCode, String)
}

///|
/// Answers `ListHostDirectory`: child directories of one absolute host path
/// (the current workspace root when the path is empty), dot-entries skipped,
/// repository-looking children flagged. Listing never leaves the browse
/// scope — an out-of-scope path is rejected before any filesystem probe, so
/// even existence outside the scope is not disclosed.
async fn list_host_directory_packet(
  workspace : NativeWorkspace,
  request : @remote_protocol.HostPathRequest,
) -> String {
  guard !(workspace.browse_scope is BrowseDisabled) else {
    return browse_disabled_error(request.id).to_json().stringify()
  }
  let target = if request.path == "" {
    // Start at the served root; fall back to the scope root when the served
    // root itself lies outside an explicit --browse-root scope.
    if workspace.browse_scope.allows(workspace.root.val) {
      workspace.root.val
    } else {
      match workspace.browse_scope {
        BrowseWithin(scope) => scope
        BrowseHost | BrowseDisabled => workspace.root.val
      }
    }
  } else {
    request.path
  }
  let packet : @remote_protocol.ServerPacket = match
    resolve_host_directory_listing(workspace.browse_scope, target) {
    HostDirectoryListOk(listing) =>
      HostDirectoryListed({ id: request.id, listing })
    HostDirectoryListError(code, message) =>
      host_browse_error(request.id, code, message)
  }
  packet.to_json().stringify()
}

///|
async fn resolve_host_directory_listing(
  scope : HostBrowseScope,
  target : String,
) -> HostDirectoryListResult {
  let real = realpath_or_normalized(target)
  if !scope.allows(real) {
    return HostDirectoryListError(
      InvalidUri,
      "Path is outside the allowed browse scope.",
    )
  }
  if (Some(@fs.exists(real)) catch { _ => None }) != Some(true) {
    return HostDirectoryListError(FileNotFound, "Directory does not exist.")
  }
  match (Some(@fs.kind(real)) catch { _ => None }) {
    Some(Directory) => ()
    _ =>
      return HostDirectoryListError(
        FileNotADirectory,
        "Path is not a directory.",
      )
  }
  let names = Some(@fs.readdir(real, include_hidden=true)) catch { _ => None }
  guard names is Some(names) else {
    return HostDirectoryListError(Unavailable, "Unable to read directory.")
  }
  // MoonBit's default String order is shortlex; a directory picker wants
  // plain lexicographic order.
  names.sort_by(compare_names_lexicographic)
  let entries : Array[@remote_protocol.HostDirectoryEntry] = []
  for name in names {
    // The picker chooses repositories; hidden directories are noise there.
    guard !name.has_prefix(".") else { continue }
    let child = join_host_path(real, name)
    if (Some(@fs.kind(child)) catch { _ => None }) is Some(Directory) {
      entries.push({
        name,
        path: child,
        is_workspace: looks_like_repository_root(child),
      })
    }
  }
  HostDirectoryListOk({
    path: real,
    parent: scope.visible_parent(real),
    entries,
  })
}

///|
/// Answers `SwitchWorkspace`: validates the target, re-roots the shared
/// workspace state, responds to the requester with its request id, and pushes
/// an uncorrelated `WorkspaceSwitched` to every other session so all
/// connected clients move together.
async fn switch_workspace_packets(
  workspace : NativeWorkspace,
  session : Int,
  outbox : @aqueue.Queue[String],
  request : @remote_protocol.HostPathRequest,
) -> Unit {
  guard !(workspace.browse_scope is BrowseDisabled) else {
    outbox.put(browse_disabled_error(request.id).to_json().stringify())
    return
  }
  match resolve_switch_target(workspace.browse_scope, request.path) {
    HostSwitchTarget(real) => {
      workspace.switch_root(real)
      let name = host_basename(real)
      let response : @remote_protocol.ServerPacket = WorkspaceSwitched({
        id: request.id,
        root: real,
        name,
      })
      outbox.put(response.to_json().stringify())
      let push : @remote_protocol.ServerPacket = WorkspaceSwitched({
        id: "",
        root: real,
        name,
      })
      workspace.sessions.broadcast_except(session, push.to_json().stringify())
    }
    HostSwitchError(code, message) => {
      let error : @remote_protocol.ServerPacket = host_browse_error(
        request.id,
        code,
        message,
      )
      outbox.put(error.to_json().stringify())
    }
  }
}

///|
priv enum HostSwitchResult {
  HostSwitchTarget(String)
  HostSwitchError(@workspace.FileSystemProviderErrorCode, String)
}

///|
async fn resolve_switch_target(
  scope : HostBrowseScope,
  path : String,
) -> HostSwitchResult {
  if path == "" {
    return HostSwitchError(InvalidUri, "Workspace path is required.")
  }
  let real = realpath_or_normalized(path)
  if !scope.allows(real) {
    return HostSwitchError(
      InvalidUri,
      "Path is outside the allowed browse scope.",
    )
  }
  if (Some(@fs.exists(real)) catch { _ => None }) != Some(true) {
    return HostSwitchError(FileNotFound, "Workspace directory does not exist.")
  }
  match (Some(@fs.kind(real)) catch { _ => None }) {
    Some(Directory) => HostSwitchTarget(real)
    _ =>
      HostSwitchError(FileNotADirectory, "Workspace path is not a directory.")
  }
}

///|
/// Uncorrelated announcement of the currently served workspace, enqueued to
/// every newly attached session so a client that reconnected across a switch
/// can reconcile stale editor state against the live root.
fn workspace_announce_packet(workspace : NativeWorkspace) -> String {
  let root = workspace.root.val
  let packet : @remote_protocol.ServerPacket = WorkspaceSwitched({
    id: "",
    root,
    name: host_basename(root),
  })
  packet.to_json().stringify()
}

///|
fn host_browse_error(
  request_id : String,
  code : @workspace.FileSystemProviderErrorCode,
  message : String,
) -> @remote_protocol.ServerPacket {
  RemoteError(
    ProtocolError(
      ProtocolProviderError,
      message,
      request_id~,
      provider_code=code.to_string(),
    ),
  )
}

///|
/// A directory reads as a repository root when it carries MoonBit module
/// metadata or a git checkout marker.
async fn looks_like_repository_root(path : String) -> Bool {
  for marker in ["moon.mod", "moon.mod.json", ".git"] {
    if (Some(@fs.exists(join_host_path(path, marker))) catch { _ => None }) ==
      Some(true) {
      return true
    }
  }
  false
}

///|
/// Code-unit lexicographic comparison (the default `String` order is
/// shortlex, which reads wrongly in a file listing).
fn compare_names_lexicographic(left : String, right : String) -> Int {
  let shared = left.length().min(right.length())
  for index in 0.. String {
  if base == "/" {
    "/\{name}"
  } else {
    "\{base}/\{name}"
  }
}

///|
/// The parent of a normalized absolute path; `None` at the filesystem root.
fn parent_host_path(path : String) -> String? {
  if path == "/" {
    return None
  }
  match path.rev_find("/") {
    Some(0) => Some("/")
    Some(index) => Some(path[0:index].to_owned())
    None => None
  }
}

///|
fn host_basename(path : String) -> String {
  if path == "/" {
    return "/"
  }
  match path.rev_after("/") {
    Some(name) => name.to_owned()
    None => path
  }
}