/**
 * Thin fetch wrapper for the Revision Control server API
 * (see CambiumServer/server.py).
 *
 * Every method resolves to { ok: true, ...payload } or { ok: false, error } —
 * network failures and HTTP errors are both normalized, so callers never
 * need try/catch.
 *
 * Requests go through platform.apiFetch: on desktop that executes in Rust
 * (tauri-plugin-http), because the webview's own fetch blocks plain-http
 * servers on non-localhost addresses (LAN / tailnet) as mixed content on
 * macOS and Windows.
 */

import { apiFetch } from '../platform';
import { dataUrlToBytes } from '../data/avatarCache';

// The version helpers live in ../version so platform.js can use them without
// importing this module (which imports platform.js). Re-exported here because
// callers reach for them alongside RcApi.
export {
  CLIENT_VERSION,
  CLIENT_VERSION_HEADER,
  majorOf,
  isCompatible,
  incompatibilityMessage,
} from '../version';

export class RcApi {
  constructor(ip) {
    // Bare host:port defaults to https — the server speaks TLS out of the box.
    // An explicit http:// prefix is honored for disable_tls dev servers.
    this.baseUrl = /^https?:\/\//.test(ip) ? ip : `https://${ip}`;
    this.host = this.baseUrl.replace(/^https?:\/\//, '');
    this.token = null;
    // The active project rides on every request as an X-Project header. It is
    // set once a project is selected on the Projects page; org-scoped endpoints
    // ignore it server-side, so it is safe to always attach when present.
    this.activeProjectId = null;
    // Wired by ConnectionContext right after a successful login: attempts a
    // silent re-login from the stored credential when a token has expired. Returns
    // the new token on success, or null/undefined if there's nothing to
    // silently recover with (caller then falls back to the login screen).
    this.onAuthExpired = null;
  }

  setActiveProject(projectId) {
    this.activeProjectId = projectId ?? null;
  }

  avatarUrl(avatarPath) {
    return avatarPath ? `${this.baseUrl}${avatarPath}` : null;
  }

  // 401 handling shared by both response branches below: token expiry gets
  // ONE silent retry via the credential-backed re-login hook. `/api/login`
  // and `/api/session/resume` are both excluded so a bad password or a dead
  // remember-token don't recurse into themselves — onAuthExpired's own retry
  // calls resume(), and the server answers every resume rejection (expired,
  // wrong device, unknown user, wrong token) with a 401 just like login does,
  // so without this exclusion a dead token would re-enter this hook forever.
  // `_retried` guards against retrying more than once per call.
  async _retryAfter401(method, path, options) {
    if (this.onAuthExpired && !options._retried && path !== '/api/login' && path !== '/api/session/resume') {
      const newToken = await this.onAuthExpired();
      if (newToken) {
        this.token = newToken;
        return this.request(method, path, { ...options, _retried: true });
      }
    }
    return null;
  }

  async request(method, path, options = {}) {
    const { json, body, headers, binary } = options;
    try {
      const response = await apiFetch(`${this.baseUrl}${path}`, {
        method,
        headers: {
          // The client-version header is attached centrally by platform.js's
          // apiFetch, so it covers callers that don't come through here
          // (avatarCache downloads avatars via apiFetch directly).
          ...(json !== undefined ? { 'Content-Type': 'application/json' } : {}),
          ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
          ...(this.activeProjectId ? { 'X-Project': this.activeProjectId } : {}),
          ...headers,
        },
        body: json !== undefined ? JSON.stringify(json) : body,
        binary,
      });
      if (binary) {
        // Binary responses (avatar bytes) never carry a JSON payload — the
        // body is a base64 string end to end (see pinned_http::pinned_fetch).
        if (!response.ok) {
          if (response.status === 401) {
            const retried = await this._retryAfter401(method, path, options);
            if (retried) return retried;
          }
          return { ok: false, status: response.status, error: `Server error (${response.status})` };
        }
        return { ok: true, bodyBase64: await response.text() };
      }
      const payload = await response.json().catch(() => ({}));
      if (!response.ok) {
        if (response.status === 401) {
          const retried = await this._retryAfter401(method, path, options);
          if (retried) return retried;
        }
        return { ok: false, status: response.status, error: payload.error ?? `Server error (${response.status})` };
      }
      return { ok: true, ...payload };
    } catch (err) {
      if (String(err).includes('PIN_MISMATCH')) {
        return { ok: false, pinMismatch: true, error: 'Server identity changed — connection refused.' };
      }
      if (String(err).includes('NO_PIN')) {
        return { ok: false, trustRequired: true, error: 'Server not yet trusted.' };
      }
      return { ok: false, error: 'Could not reach the server. Is it running?' };
    }
  }

  // Avatar bytes via the pinned channel, as a data: URL for <img src>. The
  // desktop webview can't fetch an avatar itself (self-signed cert), so
  // bytes ride the same pinned_fetch path as every other request.
  async fetchAvatarDataUrl(avatarPath) {
    if (!avatarPath) return null;
    const result = await this.request('GET', avatarPath, { binary: true });
    return result.ok && result.bodyBase64
      ? `data:image/png;base64,${result.bodyBase64}`
      : null;
  }

  async login({ username, password, deviceId, deviceName, remember = false }) {
    const result = await this.request('POST', '/api/login', {
      // The client version travels as a header on every request (see
      // `request`), not as a body field — the server never read this one.
      json: { username, password, deviceId, deviceName, remember },
    });
    if (result.ok) this.token = result.token;
    return result;
  }

  // Start a session from a stored remember-token instead of a password. The
  // server does not rotate the token, so the caller's stored copy stays
  // valid and must not be overwritten from this response.
  async resume({ username, rememberToken, deviceId }) {
    const result = await this.request('POST', '/api/session/resume', {
      json: { username, rememberToken, deviceId },
    });
    if (result.ok) this.token = result.token;
    return result;
  }

  logout() {
    return this.request('POST', '/api/logout', { json: {} });
  }

  // Projects the caller is a member of:
  // [{ id, name, sizeBytes, memberCount, lastCommitAt }].
  listProjects() {
    return this.request('GET', '/api/projects');
  }

  // Redeem an invite code to join a project. → { ok, project } or
  // { ok:false, error:"Invalid code." }.
  joinProject(code) {
    return this.request('POST', '/api/projects/join', { json: { code } });
  }

  getServer() {
    return this.request('GET', '/api/server');
  }

  getUsers() {
    return this.request('GET', '/api/users');
  }

  getRevisions() {
    return this.request('GET', '/api/revisions');
  }

  getDepotTree() {
    return this.request('GET', '/api/depot/tree');
  }

  // The depot's head ignore.txt (p4ignore-style), as { text }. Empty when no
  // ignore.txt has been committed.
  getIgnore() {
    return this.request('GET', '/api/ignore');
  }

  getLocks() {
    return this.request('GET', '/api/locks');
  }

  // Devices for the manager. scope='self' forces the caller's own devices even
  // for an admin (the login-limit popup); otherwise admins get everyone's.
  // → { devices: [{ deviceId, name, hostname, filesCheckedOut, isCurrent, owner? }], maxDevices, admin }.
  getDevices(scope) {
    return this.request('GET', scope === 'self' ? '/api/devices?scope=self' : '/api/devices');
  }

  // Remove a device: force-checks-in everything it holds and drops it. → { ok, released }.
  removeDevice(deviceId) {
    return this.request('DELETE', '/api/devices', { json: { deviceId } });
  }

  // Force check-in one file a device holds (across projects). → { ok, released }.
  checkinDeviceFile({ deviceId, projectId, path }) {
    return this.request('POST', '/api/devices/checkin', { json: { deviceId, projectId, path } });
  }


  releaseFiles(paths) {
    return this.request('POST', '/api/files/release', { json: { paths } });
  }

  forceUnlockFiles(paths) {
    return this.request('POST', '/api/files/unlock', { json: { paths } });
  }

  // Direct download URL for a depot file's head content — consumed by the
  // native (Rust) layer (workspace:pull-files), which attaches authHeaders().
  fileContentUrl(path) {
    return `${this.baseUrl}/api/files/content?path=${encodeURIComponent(path)}`;
  }


  // Roll one depot path back to its state at `revision`. The server records a
  // normal forward revision ("Rollback <path> to revision <N>") reusing the old
  // content, so history stays linear. → { ok, revision, change, locks }.
  rollbackFile(path, revision) {
    return this.request('POST', '/api/rollback', { json: { path, revision } });
  }

  // Headers the native (Rust) layer attaches when it streams file content /
  // upload bodies on our behalf. Those routes are project-scoped, so the
  // X-Project header must ride along just as it does on request().
  authHeaders() {
    return {
      ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
      ...(this.activeProjectId ? { 'X-Project': this.activeProjectId } : {}),
    };
  }

  getAvailability() {
    return this.request('GET', '/api/availability');
  }

  setMyAvailability(slots) {
    return this.request('PUT', '/api/availability/me', { json: { slots } });
  }

  createMeeting(meeting) {
    return this.request('POST', '/api/meetings', { json: meeting });
  }

  updateMeeting(id, patch) {
    return this.request('PATCH', `/api/meetings/${encodeURIComponent(id)}`, { json: patch });
  }

  deleteMeeting(id) {
    return this.request('DELETE', `/api/meetings/${encodeURIComponent(id)}`);
  }

  patchAccount(patch) {
    return this.request('PATCH', '/api/account', { json: patch });
  }

  changePassword(oldPassword, newPassword) {
    return this.request('POST', '/api/account/password', {
      json: { oldPassword, newPassword },
    });
  }

  renameDevice(name) {
    return this.request('PATCH', '/api/account/device', { json: { name } });
  }

  async uploadAvatar(dataUrl) {
    // The account modal produces a canvas PNG data URL; ship the raw bytes.
    // Decoded in pure JS — fetch(dataUrl) would run through the webview's
    // fetch, which the API layer deliberately avoids (see header comment).
    return this.request('PUT', '/api/avatars/me', {
      body: dataUrlToBytes(dataUrl),
      headers: { 'Content-Type': 'image/png' },
    });
  }

  // --- Server Management (admin) ---
  getAdminConfig() {
    return this.request('GET', '/api/admin/config');
  }

  saveAdminConfig(config) {
    return this.request('PUT', '/api/admin/config', { json: { config } });
  }

  getAdminProjects() {
    return this.request('GET', '/api/admin/projects');
  }

  createProject(name) {
    return this.request('POST', '/api/admin/projects', { json: { name } });
  }

  setProjectMember(projectId, username, member) {
    return this.request('POST', `/api/admin/projects/${encodeURIComponent(projectId)}/members`, {
      json: { username, member },
    });
  }

  removeProjectMember(projectId, handle) {
    return this.request('DELETE', `/api/admin/projects/${encodeURIComponent(projectId)}/members`, {
      json: { handle },
    });
  }

  setPermanentCode(projectId, code, active) {
    return this.request('PUT', `/api/admin/projects/${encodeURIComponent(projectId)}/invite/permanent`, {
      json: { code, active },
    });
  }

  regenerateTemporaryCode(projectId) {
    return this.request('POST', `/api/admin/projects/${encodeURIComponent(projectId)}/invite/temporary/regenerate`);
  }

  // --- Server Management (admin users) ---
  // Create a user (reinstates a disabled handle instead). → { ok, created|reinstated }.
  adminAddUser(handle, password) {
    return this.request('POST', '/api/admin/users', { json: { handle, password } });
  }

  // Disable a user and drop their sessions. → { ok, removed }.
  adminDeleteUser(handle) {
    return this.request('DELETE', `/api/admin/users/${encodeURIComponent(handle)}`);
  }

  // Reset another user's password (owner's password: owner only). → { ok }.
  adminSetPassword(handle, password) {
    return this.request('PUT', `/api/admin/users/${encodeURIComponent(handle)}/password`, {
      json: { password },
    });
  }

  // Change a user's role ('member' | 'admin'). → { ok, role }.
  adminSetRole(handle, role) {
    return this.request('PUT', `/api/admin/users/${encodeURIComponent(handle)}/role`, {
      json: { role },
    });
  }

  // Stop the server process. Relaunching it is the operator's job. → { ok }.
  shutdownServer() {
    return this.request('POST', '/api/admin/shutdown');
  }

  // Irreversibly delete a project and all its data. Owner role only. → { ok }.
  deleteProject(projectId) {
    return this.request('DELETE', `/api/admin/projects/${encodeURIComponent(projectId)}`);
  }
}
