/**
 * Typed REST client for backreload/v1.
 *
 * Authentication: WordPress nonce, injected via wp_localize_script as window.Backreload.
 */

declare global {
  interface Window {
    Backreload?: {
      apiUrl: string;
      nonce: string;
      version: string;
      siteUrl: string;
      /** Smallest of upload_max_filesize / post_max_size, informational. */
      maxUploadBytes?: number;
      /** Dark wordmark, for light backgrounds (sidebar/topbar). */
      logoUrl?: string;
      /** White wordmark, for dark backgrounds (maintenance page, future dark UI). */
      logoWhiteUrl?: string;
      /** Which React page to open on boot — set by class-admin.php from
       *  the currently-loaded WP admin submenu slug. */
      initialPage?: string;
      /** Data-only build: Restore wizard hides plugins/themes. Default false. */
      dataOnly?: boolean;
    };
  }
}

export const bk = {
  apiUrl:         window.Backreload?.apiUrl ?? '/wp-json/backreload/v1/',
  nonce:          window.Backreload?.nonce  ?? '',
  version:        window.Backreload?.version ?? '',
  maxUploadBytes: window.Backreload?.maxUploadBytes ?? 0,
  logoUrl:        window.Backreload?.logoUrl ?? '',
  logoWhiteUrl:   window.Backreload?.logoWhiteUrl ?? '',
  initialPage:    window.Backreload?.initialPage ?? 'dashboard',
  dataOnly:       window.Backreload?.dataOnly ?? false,
};

type Json = Record<string, unknown> | unknown[];

export class ApiError extends Error {
  constructor(public status: number, message: string, public payload?: unknown) {
    super(message);
  }
}

/**
 * Strip HTML tags + collapse whitespace from a message.
 * WordPress's "critical error" response embeds raw HTML in `message` —
 * we don't want that leaking into our toasts.
 */
function cleanErrorMessage(raw: string): string {
  if (!raw) return 'Request failed';
  // Replace tags with spaces (so words don't collapse together), then decode entities + trim.
  const stripped = raw.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
  // If WP sent the generic critical-error blob, surface something more useful.
  if (/critical error on this website/i.test(stripped)) {
    return 'Server error (HTTP 500). Check the plugin Logs page for details.';
  }
  return stripped || 'Request failed';
}

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(bk.apiUrl.replace(/\/$/, '') + '/' + path.replace(/^\//, ''), {
    credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json',
      'X-WP-Nonce':   bk.nonce,
      ...(init.headers || {}),
    },
    ...init,
  });

  let body: any = null;
  const raw = await res.text();
  if (raw) {
    try {
      body = JSON.parse(raw);
    } catch {
      // Defensive salvage: some cache/optimization plugins (e.g. Optimazz,
      // and various "add HTML comment" cache signatures) append or prepend an
      // HTML comment like `<!-- ... cache ... -->` to EVERY response — including
      // REST API JSON. That trailing/leading junk breaks a strict JSON.parse
      // and would blank the entire admin UI. Strip surrounding well-formed HTML
      // comments and retry once. Done with bounded indexOf slicing rather than
      // a regex: a regex like /(?:<!--[\s\S]*?-->)+$/ backtracks quadratically
      // on a crafted body full of unclosed "<!--", which could hang the tab.
      let salvaged = raw.trim();
      for (let i = 0; i < 8 && salvaged.endsWith('-->'); i++) {
        const open = salvaged.lastIndexOf('<!--');
        if (open === -1) break;
        salvaged = salvaged.slice(0, open).trimEnd();
      }
      for (let i = 0; i < 8 && salvaged.startsWith('<!--'); i++) {
        const close = salvaged.indexOf('-->');
        if (close === -1) break;
        salvaged = salvaged.slice(close + 3).trimStart();
      }
      if (salvaged && salvaged !== raw) {
        try { body = JSON.parse(salvaged); } catch {}
      }
    }
  }
  if (!res.ok) {
    throw new ApiError(res.status, cleanErrorMessage(body?.message || res.statusText), body);
  }
  return body as T;
}

const j = (body: Json) => ({ body: JSON.stringify(body) });

// ───── Types ─────────────────────────────────────────────────────────────

export type BackupStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelling' | 'cancelled' | 'skipped';
// Note: 'incremental' was reserved for true diff backups but isn't implemented
// (UpdraftPlus-style chains are fragile and add little value for typical WP sites).
// Backend still tolerates the value if it ever appears in legacy records.
export type BackupType   = 'full' | 'database' | 'files';

/** A single archive part within a multi-zip backup. */
export interface BackupFile {
  name: string;
  category: 'plugins' | 'themes' | 'uploads' | 'mu' | 'others' | 'db' | string;
  size: number;
  checksum: string;
  encrypted: boolean;
  uploaded_to: string[];
}

/**
 * Provenance of a backup. The PHP side writes any of these as the `kind`
 * column on the backup record:
 *  - `manual`     — user clicked Backup Now
 *  - `scheduled`  — fired by WP-Cron from a Schedule row
 *  - `migrating`  — built by the migration push engine as a one-shot, NOT
 *                   a normal historical backup (filtered from the Backups
 *                   list; see `exclude_kind` on BackupRecord::all)
 *  - `migrated`   — created on the destination side after a successful
 *                   site-to-site receive (acts as a historical record of
 *                   what was imported, in case the operator wants to roll
 *                   back to it later)
 *
 * Keep this in sync with includes/api/class-migration-controller.php and
 * includes/core/class-migration-push-engine.php where these values are
 * written. The TS bundle used to declare only `manual | scheduled`,
 * which silently lied — a Backups page row whose `kind` was actually
 * `migrating` would type-check as `manual | scheduled` and any narrowing
 * logic based on the union would be wrong.
 */
export type BackupKind = 'manual' | 'scheduled' | 'migrating' | 'migrated';

export interface Backup {
  id: string;
  type: BackupType;
  kind: BackupKind;
  status: BackupStatus;
  size: number;
  duration: number;
  encrypted: boolean;
  destinations: string[];
  components: string[];
  /** Multi-zip parts. Empty array for legacy single-zip backups. */
  files: BackupFile[];
  checksum: string | null;
  error: string | null;
  /** Source site URL (captured when the backup was taken). Null for legacy or unknown. */
  source_url: string | null;
  /** Source filesystem path (ABSPATH). Null if not captured. */
  source_path: string | null;
  date: string | null;
  completed_at: string | null;
  /**
   * True if the most recent sync confirmed this backup's folders are no
   * longer present on any of its destinations — usually because the user
   * deleted the bk_* folders directly on the server (over SSH/FTP). The UI
   * shows a warning chip on these rows and offers a one-click cleanup so
   * the orphan DB rows don't accumulate forever.
   *
   * Note: optional because the server only started emitting this field in
   * the broken-record sweep — older API consumers won't break, and a fresh
   * install with no sync ever performed has the flag absent everywhere.
   */
  missing_on_destination?: boolean;
}

export type DestinationType =
  | 'local' | 'ftp' | 'sftp' | 'google' | 'onedrive'
  | 'aws' | 'wasabi' | 's3';

export interface SchemaField {
  key: string;
  label: string;
  type: 'text' | 'password' | 'int' | 'bool' | 'select';
  required?: boolean;
  secret?: boolean;
  help?: string;
  options?: string[];
  default?: string | number | boolean;
}

export interface Destination {
  id: string;
  type: DestinationType;
  name: string;
  status: 'connected' | 'disconnected' | 'error';
  /** User-toggled "skip me for now" flag. Paused destinations stay configured
   *  and visible but the backup engine + retention sweep skip them. */
  paused: boolean;
  config: Record<string, string | number | boolean>;
  last_tested: string | null;
  created_at: string | null;
  /**
   * Live storage usage report. All values are in BYTES so the UI can format
   * them via formatBytes(). The previous shape used GB as a float which
   * silently rounded sub-GB backups to "0 GB".
   *
   *   used  — bytes occupied by this destination's data (only Local reports
   *           this; remote destinations would need a separate API call we
   *           don't make on every render).
   *   free  — bytes still available on the underlying filesystem.
   *   total — capacity of the filesystem. NOT a plugin-owned quota — it's
   *           the size of the disk the folder sits on, surfaced so the UI
   *           can render a disk-fullness bar.
   *
   * `quota` is the legacy field name for `total`; kept optional on the type
   * so any cached old responses don't crash the UI before they expire.
   */
  usage?: { used: number; free?: number; total?: number; quota?: number };
}

export interface DestinationCatalogItem {
  type: DestinationType;
  label: string;
  schema: SchemaField[];
  /**
   * Server-side check for whether this destination type can actually run on
   * the current host (e.g. SFTP needs php-ssh2 installed). When
   * `availability.available === false`, the picker dims the tile, shows the
   * reason inline, and blocks save with the suggested fix message. Without
   * this, users only discovered the missing extension at save time via a
   * one-line toast — easy to miss.
   */
  availability?: {
    available: boolean;
    reason: string;
    fix: string;
  };
  /**
   * Optional step-by-step "where do I get these credentials?" walkthrough
   * for destinations that need provider-side setup (Google Cloud Console
   * for Drive, Azure AD for OneDrive). React renders this as a collapsible
   * details block inside the config form.
   *
   * `steps[].html` is server-controlled HTML — limited by convention to
   * <strong>, <code>, and <a> tags — so it's safe to render via
   * dangerouslySetInnerHTML (we don't echo user input into it).
   *
   * `callouts` surface values the user has to paste into the provider's
   * console (e.g. the OAuth redirect URI), rendered as copy-friendly code
   * boxes so the user doesn't accidentally fat-finger them.
   */
  setup_help?: {
    title: string;
    provider: string;
    steps: string[];
    callouts?: Array<{
      label: string;
      value: string;
      help?: string;
    }>;
  };
}

export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly';

export interface Schedule {
  id: string;
  name: string;
  type: 'full' | 'database' | 'files';
  frequency: ScheduleFrequency;
  time_of_day: string;   // "HH:MM"
  retention: number;     // 1–365
  enabled: boolean;
  /** Destination ext_ids this schedule's backups should land on.
   *  Empty array = "every connected destination" (legacy / default). */
  destinations: string[];
  created_at: string;
}

export interface UpcomingRun {
  schedule_id: string;
  schedule_name: string;
  type: 'full' | 'database' | 'files';
  at: string; // ISO
}

export interface LogEntry {
  id: number;
  backup_ext_id: string | null;
  level: 'info' | 'success' | 'warn' | 'error';
  message: string;
  context: string | null;
  created_at: string;
}

export interface DashboardData {
  last_backup: Backup | null;
  total_count: number;
  storage_used: number;
  success_rate: number;
  next_run: string | null;
  series_7d: { day: string; mb: number }[];
  /** Per-destination storage breakdown. All sizes in BYTES (see Destination.usage). */
  storage_by_destination: {
    name: string;
    type: DestinationType;
    used: number;
    free?: number;
    total?: number;
    /** Legacy alias for `total` — kept optional during the transition. */
    quota?: number;
  }[];
  recent: Backup[];
}

// ───── API ──────────────────────────────────────────────────────────────

export const api = {
  dashboard: () => request<DashboardData>('dashboard'),

  backups: {
    // Pull the full server-side cap (200) by default. Listing + filtering is cheap
    // on the client, and it lets the Restore picker's search find older backups
    // without us building a paginated REST contract for what's still a small N.
    list:    (limit = 200) => request<{ items: Backup[]; broken_count?: number }>(`backups?limit=${limit}`),
    get:     (id: string) => request<Backup>(`backups/${id}`),
    create:  (data: { type?: BackupType; components?: string[]; destinations?: string[] }) =>
      request<Backup>('backups', { method: 'POST', ...j(data) }),
    remove:  (id: string) => request<{ deleted: true }>(`backups/${id}`, { method: 'DELETE' }),
    restore: (id: string, data: {
      components?: string[];
      destination?: string;
      passphrase?: string;
      migrate?: boolean;
      source_url?: string;
      target_url?: string;
      source_path?: string;
      target_path?: string;
      /**
       * Clean-restore mode: wipe each selected category's target directory
       * BEFORE extracting the backup, so files added after the backup point
       * (including malware-planted ones) don't survive the restore.
       * Default: false (additive restore — overwrites same-named files but
       * leaves foreign files alone). Required for malware-cleanup scenarios.
       */
      clean_restore?: boolean;
    }) => request<{ restored: boolean }>(`backups/${id}/restore`, { method: 'POST', ...j(data) }),
    cancel:  (id: string) => request<Backup>(`backups/${id}/cancel`, { method: 'POST' }),
    resume:  (id: string) => request<Backup>(`backups/${id}/resume`, { method: 'POST' }),
    /**
     * Import an existing backup by uploading its part files.
     *
     * Each part is sliced into small chunks and POSTed one at a time, so the
     * upload survives the server's request-size limits (PHP `post_max_size`,
     * nginx `client_max_body_size`) no matter how large the parts are.
     *
     * Each chunk is gzip-compressed, base64-encoded, then sent as a file upload
     * (`chunk`). A file upload clears mod_security's generous size limit; and
     * because firewalls base64-DECODE content to re-scan it, the gzip step is
     * what actually hides the PHP source inside theme/plugin zips — the decoded
     * bytes are opaque, high-entropy data, so the scanner that returns "406 Not
     * Acceptable" for readable code never fires.
     *
     * The first part creates the backup record; the rest append to it. Returns
     * the final BackupRecord. `onProgress` reports 0–100 across every chunk.
     */
    import: async (files: File[], onProgress?: (pct: number) => void): Promise<Backup> => {
      // Raw chunk size. base64 inflates by ~33%, so 700 KB raw → ~0.93 MB
      // request body — safely under a default ~1 MB server request cap.
      const CHUNK = 700_000;
      const totalBytes = Math.max(1, files.reduce((s, f) => s + f.size, 0));
      let sentBytes = 0;
      let backupId = '';
      let lastBackup: Backup | null = null;

      /** Gzip-compress a Blob via the browser's CompressionStream API. */
      const gzipBlob = async (blob: Blob): Promise<Blob> => {
        const cs = new CompressionStream('gzip');
        return new Response(blob.stream().pipeThrough(cs)).blob();
      };

      /** Read a Blob as a base64 string (without the data: URL prefix). */
      const blobToBase64 = (blob: Blob): Promise<string> =>
        new Promise((resolve, reject) => {
          const r = new FileReader();
          r.onload = () => {
            const s = String(r.result);
            const comma = s.indexOf(',');
            resolve(comma >= 0 ? s.slice(comma + 1) : s);
          };
          r.onerror = () => reject(r.error ?? new Error('Could not read file chunk.'));
          r.readAsDataURL(blob);
        });

      const sendChunk = async (fd: FormData, ctx: string): Promise<any> => {
        const res = await fetch(bk.apiUrl.replace(/\/$/, '') + '/backups/import', {
          method: 'POST',
          credentials: 'same-origin',
          headers: { 'X-WP-Nonce': bk.nonce },
          body: fd,
        });
        const raw = await res.text();
        let body: any = null;
        try { body = JSON.parse(raw); } catch { /* not JSON — server/firewall page */ }
        if (!res.ok) {
          let msg = body?.message ? cleanErrorMessage(body.message) : '';
          if (!msg || msg === 'Request failed') {
            // Non-JSON response — almost always a server firewall / proxy
            // block page. Surface the HTTP status plus a short text snippet so
            // the actual block reason is visible.
            const snippet = raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
            msg = `HTTP ${res.status}${snippet ? `: ${snippet}` : ''}`;
          }
          throw new ApiError(res.status, `${ctx}: ${msg}`, body);
        }
        return body;
      };

      for (const file of files) {
        const uploadId   = Math.random().toString(36).slice(2) + Date.now().toString(36);
        const chunkTotal = Math.max(1, Math.ceil(file.size / CHUNK));
        for (let ci = 0; ci < chunkTotal; ci++) {
          const blob = file.slice(ci * CHUNK, (ci + 1) * CHUNK);
          const b64  = await blobToBase64(await gzipBlob(blob));
          const fd = new FormData();
          // Send base64(gzip(chunk)) AS A FILE: the file framing clears the
          // firewall's size limit, and the gzip layer keeps the decoded bytes
          // opaque so no content rule matches.
          fd.append('chunk', new Blob([b64], { type: 'text/plain' }), 'chunk.txt');
          fd.append('file_name', file.name);
          fd.append('upload_id', uploadId);
          fd.append('chunk_index', String(ci));
          fd.append('chunk_total', String(chunkTotal));
          if (backupId) fd.append('backup_id', backupId);

          const res = await sendChunk(fd, `${file.name} chunk ${ci + 1}/${chunkTotal}`);
          // The final chunk of a file returns the full backup record.
          if (res && typeof res.id === 'string') {
            backupId = res.id;
            lastBackup = res as Backup;
          }
          sentBytes += blob.size;
          onProgress?.(Math.min(100, Math.round((sentBytes / totalBytes) * 100)));
        }
      }

      if (!lastBackup) throw new ApiError(0, 'Import did not produce a backup record.');
      return lastBackup;
    },

    /**
     * Firewall-proof import: the user copies the backup part files into a
     * folder on the server (FTP / SFTP / file manager), then the plugin scans
     * and assembles them — no HTTP file upload, so no firewall to clear.
     */
    importFolder: {
      scan: () => request<{
        folder: string;
        parts: Array<{ name: string; category: string; size: number }>;
        unknown: string[];
      }>('backups/import-folder'),
      commit: () => request<Backup>('backups/import-folder', { method: 'POST' }),
    },

    /**
     * Import from a storage destination: browse a configured destination for
     * backups (including ones created on another site) and recreate the chosen
     * one as a local record. The restore engine pulls the bytes on demand —
     * no upload, so no firewall to clear. The cross-server migration path.
     */
    importSource: {
      scan: (destinationId: string) =>
        request<{
          destination: string;
          backups: Array<{
            ext_id: string;
            already_imported: boolean;
            has_manifest: boolean;
            type: string;
            created_at: string;
            source_url: string;
            parts: number;
            size: number;
          }>;
        }>(`backups/import-source?destination_id=${encodeURIComponent(destinationId)}`),
      commit: (destinationId: string, extId: string) =>
        request<Backup>('backups/import-source', {
          method: 'POST',
          ...j({ destination_id: destinationId, ext_id: extId }),
        }),
    },

    /**
     * One-click sync: walk every connected, non-paused, browsable destination
     * and auto-import any bk_* folder that doesn't already have a local record.
     * Idempotent and per-destination-error-tolerant — one bad destination
     * (e.g. broken credentials after a salt rotation) doesn't abort the rest.
     */
    sync: () =>
      request<{
        imported: Array<{ ext_id: string; destination: string; source_url: string; size: number }>;
        skipped:  Array<{ ext_id: string; reason: string }>;
        errors:   Array<{ destination: string; message: string }>;
        /**
         * Records whose ext_id is no longer present on any of the destinations
         * they claim to live on — usually orphans left over after the user
         * deleted bk_* folders directly on the server. The Backups page renders
         * a "missing on destination" chip on these rows, and `cleanupBroken()`
         * sweeps them out in one click.
         */
        broken:   string[];
      }>('backups/sync', { method: 'POST' }),

    /**
     * One-click sweep of records whose destinations all report "not here"
     * (populated by the most recent sync). DB-only delete — the whole point
     * of this path is that the destinations have already confirmed the files
     * are gone, so trying to delete them remotely just floods the log with
     * 404s. Returns the ids it deleted; the UI refetches the list afterwards
     * so the warning chips disappear immediately.
     */
    cleanupBroken: () =>
      request<{
        deleted: number;
        ids: string[];
        skipped: Array<{ ext_id: string; reason: string }>;
      }>('backups/cleanup-broken', { method: 'POST' }),
  },

  destinations: {
    list:    () => request<{ items: Destination[] }>('destinations'),
    catalog: () => request<{ items: DestinationCatalogItem[] }>('destinations/catalog'),
    create:  (data: { type: DestinationType; name?: string; config: Record<string, unknown> }) =>
      request<Destination>('destinations', { method: 'POST', ...j(data) }),
    update:  (id: string, data: { name?: string; config?: Record<string, unknown> }) =>
      request<Destination>(`destinations/${id}`, { method: 'PATCH', ...j(data) }),
    remove:  (id: string) => request<{ deleted: true }>(`destinations/${id}`, { method: 'DELETE' }),
    test:    (id: string) => request<{ ok: boolean }>(`destinations/${id}/test`, { method: 'POST' }),
    pause:   (id: string) => request<Destination>(`destinations/${id}/pause`,  { method: 'POST' }),
    resume:  (id: string) => request<Destination>(`destinations/${id}/resume`, { method: 'POST' }),
  },

  schedules: {
    list:     () => request<{ items: Schedule[]; next: UpcomingRun[] }>('schedules'),
    /**
     * Clear every queued backreload_* cron event. The escape hatch for the
     * "no schedules configured but a backup keeps auto-firing" case — usually
     * caused by stale events left in the WP cron table by an old plugin
     * version OR by a restore that replayed a snapshot's cron registrations.
     * Returns the number of events removed.
     */
    purgeCron: () => request<{ removed: number }>('schedules/purge-cron', { method: 'POST' }),
    create: (data: Omit<Schedule, 'id' | 'created_at'>) =>
      request<Schedule>('schedules', { method: 'POST', ...j(data) }),
    update: (id: string, data: Partial<Schedule>) =>
      request<Schedule>(`schedules/${id}`, { method: 'PATCH', ...j(data) }),
    remove: (id: string) => request<{ deleted: true }>(`schedules/${id}`, { method: 'DELETE' }),
  },

  logs: {
    list: (params: { level?: string; search?: string; backup_id?: string; limit?: number } = {}) => {
      // URLSearchParams stringifies `undefined` as the literal "undefined"
      // string, which the backend would then treat as a real filter value
      // (matching zero rows). Strip empty/nullish values up front.
      const clean: Record<string, string> = {};
      for (const [k, v] of Object.entries(params)) {
        if (v === undefined || v === null || v === '') continue;
        clean[k] = String(v);
      }
      const qs = new URLSearchParams(clean).toString();
      return request<{ items: LogEntry[] }>(`logs${qs ? '?' + qs : ''}`);
    },
    clear: () => request<{ cleared: true }>('logs', { method: 'DELETE' }),
  },

  settings: {
    get:    () => request<Record<string, unknown>>('settings'),
    update: (data: Record<string, unknown>) =>
      request<Record<string, unknown>>('settings', { method: 'POST', ...j(data) }),
  },

  system: {
    get: () => request<{ checks: SystemCheck[]; summary: SystemSummary }>('system'),
    /**
     * Force-refresh PHP opcache + WP object cache. Shared-hosting users
     * can't restart PHP-FPM, so after a plugin file change they're stuck
     * waiting for opcache.revalidate_freq before new code loads. This
     * gives them a self-service path that doesn't need shell access.
     */
    refreshCache: () => request<{ opcache: string; wp_cache?: string }>(
      'system/refresh-cache',
      { method: 'POST' },
    ),
  },

  maintenance: {
    get:    () => request<MaintenanceState>('maintenance'),
    update: (data: Partial<MaintenanceState> & { active?: boolean }) =>
      request<MaintenanceState>('maintenance', { method: 'POST', ...j(data) }),
  },

  oauth: {
    start: (provider: 'google' | 'onedrive', destinationId: string) =>
      request<{ auth_url: string }>(`oauth/${provider}/start?destination=${encodeURIComponent(destinationId)}`),
  },

  notifiers: {
    catalog: () => request<{ items: NotifierCatalogItem[] }>('notifiers/catalog'),
    list:    () => request<{ items: ConfiguredNotifier[] }>('notifiers'),
    create:  (data: { type: string; name?: string; enabled?: boolean; on_success?: boolean; on_failure?: boolean; config: Record<string, unknown> }) =>
      request<ConfiguredNotifier>('notifiers', { method: 'POST', ...j(data) }),
    update:  (id: string, data: Partial<{ name: string; enabled: boolean; on_success: boolean; on_failure: boolean; config: Record<string, unknown> }>) =>
      request<ConfiguredNotifier>(`notifiers/${id}`, { method: 'PATCH', ...j(data) }),
    remove:  (id: string) => request<{ deleted: true }>(`notifiers/${id}`, { method: 'DELETE' }),
    test:    (id: string) => request<{ ok: boolean }>(`notifiers/${id}/test`, { method: 'POST' }),
  },

  /**
   * Site-to-site migration. Phase 1 surfaces only the receive-side key
   * management; later phases will add the source-side handshake/push and
   * the destination-side handshake/finalize endpoints.
   */
  migrate: {
    listKeys:   () => request<{ items: MigrationKey[] }>('migrate/keys'),
    createKey:  (ttl: '1h' | '8h' | '24h' | '7d', allowAutoRestore: boolean = false) =>
      request<{ key: MigrationKey; key_url: string }>('migrate/keys', {
        method: 'POST',
        ...j({ ttl, allow_auto_restore: allowAutoRestore }),
      }),
    revokeKey:  (id: string) => request<{ revoked: true }>(`migrate/keys/${id}`, { method: 'DELETE' }),
    /**
     * Validate a pasted key URL by asking the destination's /migrate/probe
     * endpoint server-to-server (we go through our own bridge controller
     * to avoid cross-origin headaches in the browser). Idempotent — does
     * NOT redeem the key; that happens at handshake time in Phase 3.
     */
    validate: (keyUrl: string) =>
      request<{
        destination_url: string;
        expires_at: number;
        wp_max_upload_bytes: number;
        plugin_version: string;
        source_url: string;
      }>('migrate/source/validate', { method: 'POST', ...j({ key_url: keyUrl }) }),

    /**
     * Phase 3b. Kicks off the actual push: handshake against the destination
     * (burns the key, gets a session token), creates a local migrating
     * BackupRecord, then schedules the first tick of the push engine. The
     * returned ext_id is what the UI polls for status.
     *
     * @param autoRestore Defaults to true. When false, the destination
     *   imports the backup but does NOT run the restore — the destination's
     *   admin has to go to the Restore page and click Restore manually.
     */
    start: (
      keyUrl: string,
      content: 'full' | 'files' | 'database',
      autoRestore: boolean = true,
    ) =>
      request<{
        ext_id: string;
        destination_url: string;
        session_id: string;
        chunk_size: number;
      }>('migrate/source/start', {
        method: 'POST',
        ...j({ key_url: keyUrl, content, auto_restore: autoRestore }),
      }),

    /**
     * Poll the current state of a running migration. Combines the build
     * progress (from the BackupRecord) with the push-phase state (chunks
     * pushed, current file, etc.) so the UI can render one progress bar.
     */
    sourceStatus: (extId: string) =>
      request<MigrationStatus>(`migrate/source/status?ext_id=${encodeURIComponent(extId)}`),

    /** Best-effort abort. Cancels local build + tells the destination to release the session. */
    sourceAbort: (extId: string) =>
      request<{ ok: true }>('migrate/source/abort', { method: 'POST', ...j({ ext_id: extId }) }),

    /**
     * Returns the ext_id of the migration currently being pushed FROM this
     * site, or null. The Send page calls this on load so a browser reload
     * re-attaches to the live progress panel instead of losing it — the push
     * keeps running server-side regardless of the page refresh.
     */
    sourceCurrent: () =>
      request<{ active: string | null }>('migrate/source/current'),

    /**
     * Destination-side: live progress of an incoming migration's auto-restore,
     * so the Receive page can show it. `active: false` when nothing is running.
     */
    /**
     * Source-side advisory scan: active plugins on this site that commonly
     * interfere with a migration, grouped by category. Purely informational.
     */
    preflight: () =>
      request<{
        cache: string[];
        security: string[];
        redirect: string[];
        builder: string[];
        elementor: boolean;
      }>('migrate/preflight'),
    receiveStatus: () =>
      request<{
        active: boolean;
        backup_id?: string;
        phase?: 'starting' | 'downloading' | 'restoring' | 'database' | 'migrating' | 'complete' | 'failed';
        label?: string;
        files_done?: number;
        files_total?: number;
        source_url?: string;
        error?: string | null;
        started_at?: number;
      }>('migrate/receive/status'),
  },
};

export interface MigrationStatus {
  phase: 'building' | 'pushing' | 'finalizing' | 'complete' | 'failed' | 'aborted';
  backup_status: string;
  destination_url: string;
  bytes_pushed: number;
  files_total: number;
  files_done?: number;
  current_file: {
    name: string;
    chunk_idx: number;
    chunk_total: number;
  } | null;
  finalized_backup_id: string | null;
  /** True if the destination auto-dispatched the restore on finalize.
   *  When false, the destination's admin still has to click Restore. */
  auto_restore_dispatched?: boolean;
  error: string | null;
  started_at?: number;
  /** On a failed migration, the phase that was active at the moment of
   *  failure. Used by the phase stepper to highlight the correct step
   *  with a red bar instead of guessing "probably pushing". Null while
   *  the migration is still in flight; null on success. */
  failed_at_phase?: 'building' | 'pushing' | 'finalizing' | null;
}

/** Migration key as returned by the REST API. The plaintext token is
 *  surfaced ONLY in the response to createKey() inside `key_url`. */
export interface MigrationKey {
  id: string;
  expires_at: number;          // unix seconds
  created_at: number;
  state: 'active' | 'redeemed' | 'revoked' | 'expired';
  /** Set once the key has been used to open a session. */
  session_id: string | null;
  /** URL of the site pushing into us. Populated by the handshake. */
  source_url: string | null;
  redeemed_at: number | null;
  last_seen_ip: string | null;
  /** Whether the destination admin explicitly authorised auto-restore on
   *  push when generating this key. Default false = "import only". */
  allow_auto_restore?: boolean;
}

export interface NotifierCatalogItem {
  type: string;
  label: string;
  description: string;
  schema: SchemaField[];
}

export interface MaintenanceState {
  active: boolean;
  heading: string;
  message: string;
  eta: string;
  bypass_ips: string[];
  started_at: string | null;
  started_by: string | null;
}

export type CheckStatus = 'pass' | 'warn' | 'fail';
export type CheckCategory = 'core' | 'php' | 'filesystem' | 'wordpress' | 'optional';
export interface SystemCheck {
  id: string;
  category: CheckCategory;
  label: string;
  value: string;
  requirement: string;
  status: CheckStatus;
  help?: string;
}
export interface SystemSummary {
  pass: number;
  warn: number;
  fail: number;
  total: number;
  health_score: number;
}

export interface ConfiguredNotifier {
  id: string;
  type: string;
  name: string;
  enabled: boolean;
  on_success: boolean;
  on_failure: boolean;
  config: Record<string, string | number | boolean>;
}
