// API client for Scriba

const API_BASE = '/api';
const ENGINE_BASE = '/api/engine';

export type EngineEstimateResult = {
  source: { files: number; totalLines: number; effectiveLines: number; totalChars: number };
  model: string;
  maxIterations: number;
  expected: { tokens: number; promptTokens: number; completionTokens: number; costUsd: number };
  p95: { tokens: number; costUsd: number };
  assumptions: Record<string, unknown>;
  byFile?: Array<{ path: string; effectiveLines: number; predictedCostUsd: number }>;
};

export type PreflightGraph = {
  programs: number;
  copybooks: number;
  unresolvedRefs: number;
  edges: { copy: number; call: number; 'cics-link': number; 'cics-xctl': number; 'sql-include': number; 'jcl-exec': number; import: number };
  sharedCopybooks: string[];
  missing: string[];
  cycles: string[][];
};

export type EnginePreflightSummary = {
  totalFiles: number;
  totalLines: number;
  byLanguage: Record<string, number>;
  dialects: Record<string, number>;
  execSqlBlocksTotal: number;
  execSqlCallsTotal: number;
  nonPortableTotal: number;
  graph?: PreflightGraph;
  /** Distinct DSN= references across all JCL members. */
  datasets?: string[];
  /** Suite-level dataset hand-off classification derived from lineage. */
  datasetFlow?: { externalInputs: string[]; terminalOutputs: string[] };
  /** z/OS-adjacent artefact counts (BMS, IMS DBD/PSB, CICS CSD, DDL). */
  mainframeArtefacts?: { bms: number; dbd: number; psb: number; csd: number; ddl: number; map: number };
};

export type ReviewFileEntry = {
  outputPath: string;
  sourcePath: string | null;
  sourceContent: string | null;
  outputContent: string;
  status: 'pending' | 'approved' | 'needs-rework';
  notes: string | null;
  reviewer: string | null;
  reviewedAt: string | null;
  annotations: Array<{ line: number | null; ruleId: string; severity: string; snippet: string }>;
};

export type EngineReviewResult = {
  runId: string;
  tenant: string;
  sealed: unknown;
  summary: { total: number; approved: number; needsRework: number; pending: number };
  property: unknown | null;
  files: ReviewFileEntry[];
};

// ── Token refresh interceptor ──────────────────────────────────────────────
// Single in-flight refresh promise so concurrent 401s don't race.
let _refreshing: Promise<boolean> | null = null;

async function tryRefresh(): Promise<boolean> {
  if (_refreshing) return _refreshing;
  _refreshing = fetch('/api/auth/refresh', { method: 'POST' })
    .then((r) => r.ok)
    .catch(() => false)
    .finally(() => { _refreshing = null; });
  return _refreshing;
}

/**
 * Drop-in replacement for fetch.
 * On 401: attempts one token refresh, then retries the original request.
 * On refresh failure: fires `scriba:auth-expired` so SessionContext can log the user out.
 */
async function fetchWithAuth(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  const res = await fetch(input, init);
  if (res.status !== 401) return res;

  const refreshed = await tryRefresh();
  if (!refreshed) {
    if (typeof window !== 'undefined') {
      window.dispatchEvent(new Event('scriba:auth-expired'));
    }
    return res;
  }

  // Retry with fresh access token (browser sends new cookie automatically)
  return fetch(input, init);
}

// ── 429 retry helper ───────────────────────────────────────────────────────
// Honors Retry-After; max 3 attempts with exponential back-off.
async function fetchWithRateLimit(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  const MAX = 3;
  let attempt = 0;
  while (true) {
    const res = await fetch(input, init);
    if (res.status !== 429 || attempt >= MAX - 1) return res;
    const header = res.headers.get('Retry-After');
    const waitSec = header ? Number(header) : Math.pow(2, attempt + 1);
    await new Promise(r => setTimeout(r, (Number.isFinite(waitSec) ? waitSec : 4) * 1000));
    attempt++;
  }
}

// ── Public API ─────────────────────────────────────────────────────────────

export const api = {
  // Auth / Session
  getSession: async () => {
    const res = await fetchWithAuth(`${API_BASE}/auth/session`);
    if (!res.ok) return null;
    return res.json() as Promise<{ authenticated: boolean; user: { id: string; email: string; name: string; role: string; tier: string; companyId: string | null; isOwner: boolean } }>;
  },

  // Projects
  getProjects: async () => {
    const res = await fetchWithAuth(`${API_BASE}/conversions`);
    if (!res.ok) {
      const err = (await res.json().catch(() => ({}))) as { error?: string };
      throw new Error(err.error ?? `Failed to fetch conversions (${res.status})`);
    }
    const data = await res.json();
    const list = data.conversions ?? data.projects ?? [];
    return { ...data, projects: list };
  },

  getProject: async (id: string) => {
    const res = await fetchWithAuth(`${API_BASE}/conversions/${id}`);
    if (!res.ok) {
      const err = (await res.json().catch(() => ({}))) as { error?: string };
      throw new Error(err.error ?? `Failed to fetch conversion (${res.status})`);
    }
    const data = await res.json();
    const entity = data.conversion ?? data.project;
    return { ...data, project: entity };
  },

  /** Project activity across the signed-in user's projects (for header notifications). */
  getActivityNotifications: async (limit = 40) => {
    const res = await fetchWithAuth(`${API_BASE}/notifications?limit=${limit}`);
    if (!res.ok) throw new Error('Failed to fetch notifications');
    return res.json() as Promise<{
      items: Array<{
        id: string;
        projectId: string;
        projectName: string;
        action: string;
        detail?: string;
        user?: string;
        timestamp: string;
      }>;
    }>;
  },

  createProject: async (project: {
    id: string;
    name: string;
    description: string;
    sourceLanguage: string;
    targetLanguage: string;
    repoUrl?: string;
    tags?: string[];
    team?: any[];
    status?: string;
    config?: Record<string, any>;
  }) => {
    const res = await fetchWithAuth(`${API_BASE}/conversions`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(project),
    });
    if (!res.ok) throw new Error('Failed to create conversion');
    const data = await res.json();
    const entity = data.conversion ?? data.project;
    return { ...data, project: entity };
  },

  updateProject: async (id: string, updates: any) => {
    const res = await fetchWithAuth(`${API_BASE}/conversions/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(updates),
    });
    if (!res.ok) throw new Error('Failed to update conversion');
    const data = await res.json();
    const entity = data.conversion ?? data.project;
    return { ...data, project: entity };
  },

  /** Confirm upfront € estimate and consume plan credits (non-skippable migration gate).
   *  `estimatedTokens` is the exact figure shown to (and accepted by) the user, so the
   *  billed price matches the displayed price; the server falls back to its own estimate. */
  approveConversionCost: async (conversionId: string, estimatedTokens?: number) => {
    const res = await fetchWithAuth(`${API_BASE}/conversions/${conversionId}/approve-cost`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(
        typeof estimatedTokens === 'number' && estimatedTokens > 0 ? { estimatedTokens } : {},
      ),
    });
    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      throw new Error((err as { error?: string }).error ?? 'Failed to approve conversion cost');
    }
    return res.json() as Promise<{ success: boolean; alreadyApproved?: boolean }>;
  },

  /** Validate proposed target packages against the public registry (npm/Maven). Best-effort. */
  validateDependencies: async (
    ecosystem: 'npm' | 'maven',
    items: Array<{ name: string; version?: string }>,
  ): Promise<{ results: Array<{ name: string; exists: boolean | null; versionOk?: boolean; latest?: string }> }> => {
    const res = await fetchWithAuth(`${API_BASE}/registry/validate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ecosystem, items }),
    });
    if (!res.ok) return { results: [] };
    return res.json();
  },

  /** Queue migration notifications from wizard prefs (`notification_outbox`; delivery via workers later). */
  enqueueMigrationNotifications: async (
    projectId: string,
    body: { event: 'complete' | 'warning' | 'failure'; detail?: string },
  ) => {
    const res = await fetchWithAuth(`${API_BASE}/conversions/${projectId}/notifications/enqueue`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (!res.ok) throw new Error('Failed to enqueue notifications');
    return res.json() as Promise<{ success: boolean; queued: number }>;
  },

  // Step progress
  recordStepProgress: async (
    projectId: string,
    stepNumber: number,
    status: 'pending' | 'in_progress' | 'completed' | 'failed',
    maxReachedStep?: number,
    metadata?: any
  ) => {
    const res = await fetchWithAuth(`${API_BASE}/conversions/${projectId}/step`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ stepNumber, status, maxReachedStep, metadata }),
    });
    if (!res.ok) throw new Error('Failed to record step progress');
    return res.json();
  },

  // Project logs
  getLogs: async (projectId: string) => {
    const res = await fetchWithAuth(`${API_BASE}/conversions/${projectId}/logs`);
    if (!res.ok) throw new Error('Failed to fetch logs');
    return res.json() as Promise<{ logs: Array<{ id: number; time: string; level: string; message: string }> }>;
  },

  saveLog: async (projectId: string, time: string, level: string, message: string) => {
    await fetchWithAuth(`${API_BASE}/conversions/${projectId}/logs`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ time, level, message }),
    });
  },

  clearLogs: async (projectId: string) => {
    await fetchWithAuth(`${API_BASE}/conversions/${projectId}/logs`, { method: 'DELETE' });
  },

  // Scriba Engine
  engine: {
    health: async () => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/health`);
      return res.json();
    },

    languages: async (): Promise<{ source?: string[]; target?: string[]; sources?: string[]; targets?: string[] }> => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/languages`);
      return res.json().catch(() => ({}));
    },

    uploadBundle: async (files: File[]): Promise<{ uploadId: string; fileCount: number; totalBytes: number }> => {
      const form = new FormData();
      files.forEach((file) => {
        form.append('files', file, file.webkitRelativePath || file.name);
      });
      const res = await fetch(`${ENGINE_BASE}/uploads`, { method: 'POST', body: form });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Upload failed');
      return data as { uploadId: string; fileCount: number; totalBytes: number };
    },

    listUploads: async (): Promise<{ uploads: Array<{ uploadId: string; createdAt: number; fileCount: number; totalBytes: number }> }> => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/uploads`);
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Failed to list uploads');
      return data as { uploads: Array<{ uploadId: string; createdAt: number; fileCount: number; totalBytes: number }> };
    },

    estimate: async (
      files: Array<{ path: string; content: string }>,
      opts?: { model?: string; maxIter?: number; breakdown?: boolean },
    ): Promise<EngineEstimateResult> => {
      const res = await fetch(`${ENGINE_BASE}/estimate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ files, ...opts }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Estimate failed');
      return data as EngineEstimateResult;
    },

    preflight: async (
      files: Array<{ path: string; content: string }>,
    ): Promise<EnginePreflightSummary> => {
      const res = await fetch(`${ENGINE_BASE}/preflight`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ files }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Preflight failed');
      return data as EnginePreflightSummary;
    },

    startRun: async (opts: {
      uploadId: string;
      sourceLanguage: string;
      targetLanguage: string;
      additionalSourceLanguages?: string[];
      framework?: string;
      sourceFramework?: string;
      intent?: 'compat-strict' | 'parity' | 'modernize';
      qualityLevel?: number;
      maxIterations?: number;
      preserveComments?: boolean;
      customRules?: string;
      includePatterns?: string;
      excludePatterns?: string;
      useStages?: boolean;
      useRepair?: boolean;
      disabledPlugins?: string[];
      dependencyMapping?: Array<{ id: string; source: string; target: string; version: string; notes: string; status: 'auto' | 'modified' | 'added' }>;
      maxCostUsd?: number;
      maxTotalTokens?: number;
      profile?: 'auto' | 'batch' | 'online' | 'library' | 'utility' | 'mixed';
    }): Promise<{ runId: string; conversionId: string; status: 'queued' }> => {
      const res = await fetch(`${ENGINE_BASE}/runs`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(opts),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) {
        const retryAfter = Number(res.headers.get('Retry-After') ?? NaN);
        if (res.status === 429) {
          const q = data as {
            error?: string;
            reason?: 'rate-limit' | 'concurrency-cap';
            observed?: { runs_last_hour?: number; in_flight?: number };
            limits?: { runs_per_hour?: number; max_concurrent?: number };
            retry_after_s?: number;
          };
          const secs = q.retry_after_s ?? (Number.isFinite(retryAfter) ? retryAfter : 60);
          throw Object.assign(
            new Error(q.error ?? 'Run quota exceeded'),
            { status: 429, retryAfter: secs, quotaPayload: q },
          );
        }
        throw Object.assign(
          new Error((data as { error?: string }).error ?? 'Failed to start run'),
          { status: res.status },
        );
      }
      return data as { runId: string; conversionId: string; status: 'queued' };
    },

    streamRun: (runId: string, lastEventId?: string): EventSource => {
      const url = lastEventId
        ? `${ENGINE_BASE}/runs/${runId}/events?lastEventId=${encodeURIComponent(lastEventId)}`
        : `${ENGINE_BASE}/runs/${runId}/events`;
      return new EventSource(url);
    },

    getRunResult: async (runId: string): Promise<{
      runId: string;
      status: string;
      result?: Record<string, unknown>;
      error?: string;
      _httpStatus?: number;
      _retryAfterSecs?: number;
    }> => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/runs/${runId}/result`);
      const data = await res.json().catch(() => ({}));
      if (!res.ok && res.status !== 409 && res.status !== 410) {
        throw new Error((data as { error?: string }).error ?? 'Failed to fetch run result');
      }
      const retryAfterRaw = res.status === 409 ? res.headers.get('Retry-After') : null;
      const retryAfterSecs = retryAfterRaw != null ? Number(retryAfterRaw) : undefined;
      return {
        ...(data as { runId: string; status: string; result?: Record<string, unknown>; error?: string }),
        _httpStatus: res.status,
        ...(retryAfterSecs != null && Number.isFinite(retryAfterSecs) ? { _retryAfterSecs: retryAfterSecs } : {}),
      };
    },

    getRunUsage: async (runId: string): Promise<{
      runId: string;
      status: string;
      accounting: unknown | null;
      redactions: { count: number; byRule: Record<string, number> } | null;
    }> => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/runs/${runId}/usage`);
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Failed to fetch run usage');
      return data as {
        runId: string;
        status: string;
        accounting: unknown | null;
        redactions: { count: number; byRule: Record<string, number> } | null;
      };
    },

    cancelRun: async (runId: string): Promise<{ runId: string; status: string }> => {
      const res = await fetch(`${ENGINE_BASE}/runs/${runId}/cancel`, { method: 'POST' });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Cancel failed');
      return data as { runId: string; status: string };
    },

    /** ML01 §8.1 — submit per-file approve/reject decision to the HITL queue. */
    submitApproval: async (
      runId: string,
      outputPath: string,
      decision: 'approve' | 'reject',
      reason?: string,
    ): Promise<{ ok: boolean }> => {
      const res = await fetch(`${ENGINE_BASE}/runs/${runId}/approvals`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ outputPath, decision, ...(reason ? { reason } : {}) }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Approval failed');
      return { ok: true };
    },

    /** ML01 §8.1 — side-by-side diff for the HITL review queue. */
    getApprovalDiff: async (
      runId: string,
      path: string,
    ): Promise<{ source: string; target: string; path: string } | null> => {
      const res = await fetchWithRateLimit(
        `${ENGINE_BASE}/runs/${runId}/approvals/diff?path=${encodeURIComponent(path)}`,
      );
      if (res.status === 404) return null;
      const data = await res.json().catch(() => null);
      if (!res.ok || !data) return null;
      return data as { source: string; target: string; path: string };
    },

    /** Clone repo (or reuse config.uploadId) and POST bundle to engine /uploads. */
    prepareUpload: async (
      projectId: string,
      opts: { force?: boolean } = {},
    ): Promise<{ uploadId: string; fileCount: number; totalBytes: number; reused: boolean }> => {
      const res = await fetchWithAuth(`${API_BASE}/conversions/${projectId}/prepare-upload`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(opts),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Failed to prepare upload');
      return data as { uploadId: string; fileCount: number; totalBytes: number; reused: boolean };
    },

    startConvert: async (opts: {
      sourceLanguage: string;
      additionalSourceLanguages?: string[];
      targetLanguage: string;
      sourcePath?: string;
      repoUrl?: string;
      /** Branch, tag, or commit SHA for repoUrl clones (§3.1) */
      ref?: string;
      accessToken?: string;
      outputPath?: string;
      framework?: string;
      sourceFramework?: string;
      /** Project-level metadata (coordinates, profile). Maps to `project` in ConvertSchema. */
      project?: {
        profile?: 'auto' | 'batch' | 'online' | 'library' | 'utility' | 'mixed';
        intent?: 'compat-strict' | 'parity' | 'modernize';
      };
      options?: {
        maxIterations?: number;
        targetAccuracy?: number;
        includeTests?: boolean;
        preserveComments?: boolean;
        /** Hard quality gate: 0 = off, 1–3 = compiles / idiomatic / production thresholds */
        qualityLevel?: number;
        /** Multi-stage translator (Structure → Bodies → Polish); opt-in */
        useStages?: boolean;
        /** Surgical repair loop on compile errors; opt-in */
        useRepair?: boolean;
        /** Project wizard / dashboard "custom translation rules" */
        customRules?: string;
        /** Comma-separated globs (wizard "Include patterns") */
        includePatterns?: string;
        /** Comma-separated globs (wizard "Exclude patterns") */
        excludePatterns?: string;
        /** IDs of post-translation modules to skip for this run. */
        disabledPlugins?: string[];
        /** User-confirmed dependency mapping (source → target). */
        dependencyMapping?: Array<{
          id: string;
          source: string;
          target: string;
          version: string;
          notes: string;
          status: 'auto' | 'modified' | 'added';
        }>;
      };
    }): Promise<{ conversionId: string }> => {
      const res = await fetch(`${ENGINE_BASE}/convert`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sourcePath: '', outputPath: './output', ...opts }),
      });
      const data = (await res.json().catch(() => ({}))) as {
        conversionId?: string;
        error?: string | Record<string, string[]>;
        retryAfter?: number;
      };
      if (!res.ok) {
        const raHeader = res.headers.get('Retry-After');
        const ra = Number(raHeader ?? data.retryAfter ?? NaN);
        if (res.status === 429) {
          const secs = Number.isFinite(ra) ? ra : 60;
          throw Object.assign(new Error(`Too many concurrent requests. Retry in about ${secs}s.`), { status: 429, retryAfter: secs });
        }
        if (res.status === 422) {
          // Zod field-level errors: flatten into readable message
          const errs = data.error;
          let msg = 'Request validation failed.';
          if (errs && typeof errs === 'object' && !Array.isArray(errs)) {
            const fields = Object.entries(errs as Record<string, string[]>)
              .map(([field, msgs]) => `${field}: ${Array.isArray(msgs) ? msgs.join(', ') : msgs}`)
              .join('; ');
            if (fields) msg = `Validation error — ${fields}`;
          } else if (typeof errs === 'string' && errs.length > 0) {
            msg = errs;
          }
          throw Object.assign(new Error(msg), { status: 422 });
        }
        const msg =
          typeof data.error === 'string' && data.error.length > 0
            ? data.error
            : res.status === 401 || res.status === 403
              ? 'Engine rejected the request — check API key / webhook signing on the server.'
              : res.status === 503
                ? 'Feature not enabled on this engine instance.'
                : 'Failed to start conversion';
        throw Object.assign(new Error(msg), { status: res.status });
      }
      const id = data.conversionId;
      if (!id || typeof id !== 'string') throw new Error('Engine did not return a conversion id');
      return { conversionId: id };
    },

    // SSE stream — no auth wrapper, EventSource handles cookies natively
    streamConversion: (conversionId: string): EventSource => {
      return new EventSource(`${ENGINE_BASE}/convert/${conversionId}/stream`);
    },

    /** §9 — cancel a running job. */
    cancelConversion: async (conversionId: string): Promise<{ status: string; conversionId: string }> => {
      const res = await fetch(`${ENGINE_BASE}/convert/${conversionId}`, { method: 'DELETE' });
      if (res.status === 404) throw new Error('Job not found or already closed');
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Cancel failed');
      return data as { status: string; conversionId: string };
    },

    /** §8.1 — approve files in a running or finished job. */
    approveFiles: async (
      conversionId: string,
      filePaths: string[],
    ): Promise<{ approved: string[]; missing: string[]; total: number }> => {
      const res = await fetch(`${ENGINE_BASE}/convert/${conversionId}/approve`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ filePaths }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Approve failed');
      return data as { approved: string[]; missing: string[]; total: number };
    },

    /** §8.2 — remove approvals. */
    unapproveFiles: async (
      conversionId: string,
      filePaths: string[],
    ): Promise<{ approved: string[]; missing: string[]; total: number }> => {
      const res = await fetch(`${ENGINE_BASE}/convert/${conversionId}/unapprove`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ filePaths }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Unapprove failed');
      return data as { approved: string[]; missing: string[]; total: number };
    },

    /** §8.4 — get diff between iterations for a file. */
    getFileDiff: async (
      conversionId: string,
      path: string,
    ): Promise<{ conversionId: string; path: string; iter: number; before: string | null; after: string; added: number; removed: number } | null> => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/convert/${conversionId}/diff?path=${encodeURIComponent(path)}`);
      if (res.status === 404) return null;
      const data = await res.json().catch(() => null);
      if (!res.ok || !data) return null;
      return data as { conversionId: string; path: string; iter: number; before: string | null; after: string; added: number; removed: number };
    },

    /** AI-powered project manifest generation (package.json, pom.xml, etc.) from converted files. */
    scaffold: async (opts: {
      files: Array<{ path: string; content: string }>;
      targetLanguage: string;
      sourceLanguage: string;
      projectName: string;
      projectDescription?: string;
    }): Promise<{ files: Array<{ path: string; content: string }>; accounting?: { totals?: { totalTokens?: number } } }> => {
      const res = await fetch(`${ENGINE_BASE}/scaffold`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(opts),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(typeof (data as { error?: unknown }).error === 'string' ? String((data as { error: string }).error) : 'Scaffold generation failed');
      return data as { files: Array<{ path: string; content: string }>; accounting?: { totals?: { totalTokens?: number } } };
    },

    /** LLM-assisted dependency mapping proposals via scriba-engine. */
    proposeDependencies: async (opts: {
      sourceLanguage: string;
      targetLanguage: string;
      deps: string[];
      customRules?: string;
    }): Promise<{
      proposals: Array<{ source: string; target: string; version: string; notes: string }>;
      accounting?: { totals?: { totalTokens?: number } };
    }> => {
      // Abort a slow/hung engine after 25s so the caller can fall back to the local proposer.
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), 25_000);
      try {
        const res = await fetch(`${ENGINE_BASE}/propose-dependencies`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(opts),
          signal: ctrl.signal,
        });
        const data = await res.json().catch(() => ({}));
        if (!res.ok) throw new Error(typeof (data as { error?: unknown }).error === 'string' ? String((data as { error: string }).error) : 'Proposal generation failed');
        return data as {
          proposals: Array<{ source: string; target: string; version: string; notes: string }>;
          accounting?: { totals?: { totalTokens?: number } };
        };
      } finally {
        clearTimeout(timer);
      }
    },

    /** §12.1 — Knowledge Base CRUD (opt-in per customer). */
    customerKb: {
      list: async (customerId: string) => {
        const res = await fetchWithRateLimit(`${ENGINE_BASE}/customer/${customerId}/kb`);
        if (res.status === 503) return null;
        return res.json().catch(() => null);
      },
      add: async (
        customerId: string,
        doc: { kind: string; title: string; content: string; docId?: string },
      ) => {
        const res = await fetch(`${ENGINE_BASE}/customer/${customerId}/kb`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(doc),
        });
        if (res.status === 503) throw new Error('KB not enabled on this engine instance');
        return res.json();
      },
      remove: async (customerId: string, docId: string) => {
        const res = await fetch(`${ENGINE_BASE}/customer/${customerId}/kb/${docId}`, { method: 'DELETE' });
        if (!res.ok) throw new Error('Remove failed');
        return res.json().catch(() => ({}));
      },
      search: async (customerId: string, q: string, k = 5) => {
        const res = await fetchWithRateLimit(
          `${ENGINE_BASE}/customer/${customerId}/kb/search?q=${encodeURIComponent(q)}&k=${k}`,
        );
        if (res.status === 503) return null;
        return res.json().catch(() => null);
      },
    },

    /** AI-generated unit + integration tests for converted files. */
    generateTests: async (opts: {
      files: Array<{
        outputPath: string;
        content: string;
        sourcePath?: string;
        sourceContent?: string;
      }>;
      sourceLanguage: string;
      targetLanguage: string;
    }): Promise<{
      unit: Array<{ path: string; content: string; testCount?: number }>;
      integration: Array<{ path: string; content: string; testCount?: number }>;
      warnings: string[];
      accounting?: { totals?: { totalTokens?: number } };
    }> => {
      const res = await fetch(`${ENGINE_BASE}/generate-tests`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(opts),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(typeof (data as { error?: unknown }).error === 'string' ? String((data as { error: string }).error) : 'Test generation failed');
      return data as {
        unit: Array<{ path: string; content: string; testCount?: number }>;
        integration: Array<{ path: string; content: string; testCount?: number }>;
        warnings: string[];
        accounting?: { totals?: { totalTokens?: number } };
      };
    },

    /** §12.2 — Replay datasets (opt-in). */
    customerDataset: {
      list: async (customerId: string) => {
        const res = await fetchWithRateLimit(`${ENGINE_BASE}/customer/${customerId}/dataset`);
        if (res.status === 503) return null;
        return res.json().catch(() => null);
      },
      upload: async (customerId: string, payload: string) => {
        const res = await fetch(`${ENGINE_BASE}/customer/${customerId}/dataset`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ payload }),
        });
        if (res.status === 503) throw new Error('Dataset storage not enabled on this engine instance');
        return res.json();
      },
      remove: async (customerId: string, datasetId: string) => {
        const res = await fetch(`${ENGINE_BASE}/customer/${customerId}/dataset/${datasetId}`, { method: 'DELETE' });
        if (!res.ok) throw new Error('Remove failed');
        return res.json().catch(() => ({}));
      },
    },

    getReview: async (runId: string): Promise<EngineReviewResult> => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/reviews/${runId}`);
      const data: unknown = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Failed to fetch review');
      return data as EngineReviewResult;
    },

    setFileReview: async (
      runId: string,
      outputPath: string,
      update: { status: 'pending' | 'approved' | 'needs-rework'; notes?: string; reviewer?: string },
    ): Promise<{ outputPath: string; status: string; reviewedAt: string }> => {
      const res = await fetch(`${ENGINE_BASE}/reviews/${runId}/files/${encodeURIComponent(outputPath)}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(update),
      });
      const data: unknown = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Failed to update file review');
      return data as { outputPath: string; status: string; reviewedAt: string };
    },

    sealReview: async (runId: string, sealedBy?: string): Promise<{ runId: string; manifest: unknown }> => {
      const res = await fetch(`${ENGINE_BASE}/reviews/${runId}/seal`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sealedBy: sealedBy ?? 'user' }),
      });
      const data: unknown = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Failed to seal review');
      return data as { runId: string; manifest: unknown };
    },

    getReviewManifest: async (runId: string): Promise<{ runId: string; manifest: unknown }> => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/reviews/${runId}/manifest`);
      const data: unknown = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error((data as { error?: string }).error ?? 'Failed to fetch manifest');
      return data as { runId: string; manifest: unknown };
    },

    getReviewAuditCsv: async (runId: string): Promise<string> => {
      const res = await fetchWithRateLimit(`${ENGINE_BASE}/reviews/${runId}/audit.csv`);
      if (!res.ok) throw new Error('Failed to fetch audit CSV');
      return res.text();
    },

    getReviewFileContext: async (runId: string, filePath: string): Promise<string> => {
      const res = await fetchWithRateLimit(
        `${ENGINE_BASE}/reviews/${runId}/context/${encodeURIComponent(filePath)}?format=prompt`,
      );
      if (!res.ok) throw new Error('Failed to fetch prompt context');
      const data = await res.json().catch(() => ({})) as { prompt?: string; context?: string };
      return data.prompt ?? data.context ?? '';
    },
  },
};
