///|
/// Credential file + device-flow facet. `for_relay` derives the
/// on-disk path and the HTTP base URL from the relay WebSocket URL;
/// `acquire` reads the file or runs the device flow (writing on
/// success); `remove_stale` deletes the file after the relay has
/// rejected the token with 401/403.
struct CredentialStore {
  path : String
  http_base : @url.Url
}

///|
/// Build a CredentialStore for the given relay WebSocket URL. The
/// on-disk path is scoped per relay host
/// (`//credential.key`) so a dev relay and production
/// each keep their own token. `$TUN_CREDENTIAL_PATH` overrides for
/// containers / systemd LoadCredential. `:` in the host segment is
/// rewritten to `_` for Windows compatibility.
///
/// Raises if the WebSocket URL cannot be re-parsed into an HTTP base
/// (unreachable in practice: the ws URL is already valid).
fn CredentialStore::for_relay(ws_url : @url.Url) -> CredentialStore raise {
  let path = match @env.get_env_var("TUN_CREDENTIAL_PATH") {
    Some(p) if p != "" => p
    _ => {
      let host = ws_url.host().replace(old=":", new="_")
      "\{@xdg.data_dir()}/\{host}/credential.key"
    }
  }
  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("/")
  { path, http_base }
}

///|
/// Return a usable token: read from the credential file if present,
/// otherwise run the device flow and persist the result.
async fn CredentialStore::acquire(self : CredentialStore) -> String {
  if read_credential_file(self.path) is Some(existing) {
    @log.info("credential: loaded from \{self.path}")
    return existing
  }
  @log.info("credential: missing at \{self.path}, starting device flow")
  let token = run_device_flow(self.http_base)
  write_credential_file(self.path, token)
  @log.info("credential: wrote \{self.path}")
  token
}

///|
/// Best-effort removal after the relay has rejected the token.
/// Missing-file and permission errors are swallowed — the caller has
/// already logged the rejection, and the next run will re-pair via
/// device flow.
async fn CredentialStore::remove_stale(self : CredentialStore) -> Unit {
  @async/fs.remove(self.path) catch {
    err if @async.is_being_cancelled() => raise err
    _ => ()
  }
}

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

///|
/// 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 {
  // rfind("/") instead of @path.Path::dirname() because 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)
  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)
}

///|
/// 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
    }
  }
}

///|
/// POST a JSON body and parse the response as JSON. Raises on non-2xx.
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)
}

///|
/// Device-code flow: request a pairing code, print the verification
/// URL (OSC 8 clickable if the terminal supports it), poll until
/// approved or expired. Returns the raw `tp_*` token; raises on
/// denial/expiry/network.
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: ESC ] 8 ;;  ESC \