import type { Breakpoint, BreakpointKey, EditorConfig, GlobalSettings, RevisionInfo, Settings, ZDocument, ZElement } from '../core/types';
import type { ConditionRule, SaveConditionsResponse, TemplateOptionsQuery, TemplateOptionsResponse } from '../core/themeTemplate';

export interface DocumentResponse {
  id: number;
  title: string;
  status: string;
  data: ZDocument;
  css: string;
  pageSettings: Settings;
  permalink: string;
  modified: string;
  /** Page template ('' = theme default). */
  template?: string;
}

export interface SaveDocumentBody {
  data: ZDocument;
  css: string;
  pageSettings?: Settings;
  status?: string;
  /** Page template to store in `_wp_page_template` ('' = theme default). Omitted = leave as is. */
  template?: string;
}

export interface SaveDocumentResponse {
  saved: boolean;
  status: string;
  modified: string;
  revisions: RevisionInfo[];
  template?: string;
}

export interface GlobalResponse {
  settings: GlobalSettings;
  css: string;
  breakpoints: Record<BreakpointKey, Omit<Breakpoint, 'key'>>;
}

export interface ApiClient {
  getDocument(id: number): Promise<DocumentResponse>;
  saveDocument(id: number, body: SaveDocumentBody): Promise<SaveDocumentResponse>;
  listRevisions(id: number): Promise<RevisionInfo[]>;
  getRevision(id: number, rev: number): Promise<{ id: number; data: ZDocument }>;
  restoreRevision(id: number, rev: number): Promise<{ restored: boolean; data: ZDocument; css: string }>;
  getGlobal(): Promise<GlobalResponse>;
  saveGlobal(body: { settings: GlobalSettings; css: string; breakpoints?: Record<string, { value: number; enabled: boolean }> }): Promise<{ saved: boolean }>;
  /** `css`: handle => CSS of the documents the element renders (embedded templates, loop items); no style tags in `html`. */
  render(element: ZElement, postId?: number): Promise<{ html: string; css?: Record<string, string> }>;
  /** POST /dynamic/resolve (T114): resolved values keyed by item id. */
  resolveDynamic?(postId: number, items: Array<{ id: string; value: unknown; type: string }>): Promise<{ values: Record<string, unknown> }>;
  /** GET /dynamic/options (T114): option list for a tag control (meta keys, taxonomies, ACF fields). */
  dynamicOptions?(tag: string, control: string, search?: string, postId?: number): Promise<{ options: Array<{ value: string; label: string }> }>;
  /** GET /templates/options (T110): sub / value options of a display-condition target. */
  templateOptions?(query: TemplateOptionsQuery): Promise<TemplateOptionsResponse>;
  /** GET /<route>[?refresh=1] for selects with `optionsFrom: "rest:<route>"` (T146: email-marketing lists). */
  asyncOptions?(route: string, refresh?: boolean): Promise<{ options: Array<{ value: string; label: string }> }>;
  /** PUT /templates/{id}/conditions (T110): stores a theme template's display conditions. */
  saveTemplateConditions?(id: number, conditions: ConditionRule[]): Promise<SaveConditionsResponse>;
}

export class ApiError extends Error {
  constructor(message: string, public status: number, public code = '') {
    super(message);
  }
}

/**
 * WordPress error codes that mean "the login or the nonce is no longer valid" (cookie auth):
 *  - rest_cookie_invalid_nonce: a nonce was sent but does not match the (now missing/other) session
 *  - rest_not_logged_in / rest_forbidden with a 401: anonymous request
 */
const SESSION_CODES = new Set(['rest_cookie_invalid_nonce', 'rest_not_logged_in']);

export function isSessionExpired(e: unknown): boolean {
  return e instanceof ApiError && (e.status === 401 || SESSION_CODES.has(e.code));
}

/** Message shown instead of WordPress' "Cookie check failed" (the TopBar adds the re-login link). */
export const SESSION_EXPIRED_MESSAGE = 'Session expired, please log in again.';

export type RestConfig = Pick<EditorConfig, 'restUrl' | 'nonce'> & Partial<Pick<EditorConfig, 'adminUrl'>>;

/**
 * Asks WordPress for a fresh REST nonce (`admin-ajax.php?action=rest-nonce`, what Heartbeat uses). Succeeds
 * only while the user is still logged in: nonces expire after 12-24 h of editing, and after a re-login in
 * another tab the old nonce belongs to the previous session. Updates `config.nonce` in place.
 */
export async function refreshNonce(config: RestConfig, fetchFn: typeof fetch = fetch): Promise<boolean> {
  if (!config.adminUrl) return false;
  try {
    const res = await fetchFn(`${config.adminUrl.replace(/\/$/, '')}/admin-ajax.php?action=rest-nonce`, { credentials: 'same-origin', cache: 'no-store' });
    const text = (await res.text()).trim();
    if (res.ok && /^[a-z0-9]{6,20}$/i.test(text) && text !== '0' && text !== '-1') {
      config.nonce = text;
      return true;
    }
  } catch {
    /* network error: report the original failure */
  }
  return false;
}

export function createRestApi(config: RestConfig, fetchFn: typeof fetch = (...args) => fetch(...args)): ApiClient {
  const base = config.restUrl.replace(/\/$/, '');
  async function call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, retried = false): Promise<T> {
    const res = await fetchFn(`${base}${path}`, {
      method,
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-WP-Nonce': config.nonce,
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const text = await res.text();
    let json: any = null;
    try {
      json = text ? JSON.parse(text) : null;
    } catch {
      throw new ApiError(`Invalid JSON from ${path}`, res.status);
    }
    if (!res.ok) {
      const err = new ApiError(json?.message ?? `Request failed (${res.status})`, res.status, json?.code ?? '');
      if (isSessionExpired(err)) {
        // An expired nonce with a live session is fixed silently; a real logout surfaces a clear message.
        if (!retried && (await refreshNonce(config, fetchFn))) return call<T>(method, path, body, true);
        err.message = SESSION_EXPIRED_MESSAGE;
      }
      throw err;
    }
    return json as T;
  }
  return {
    getDocument: (id) => call('GET', `/documents/${id}`),
    saveDocument: (id, body) => call('POST', `/documents/${id}`, body),
    listRevisions: async (id) => (await call<{ revisions: RevisionInfo[] }>('GET', `/documents/${id}/revisions`)).revisions,
    getRevision: (id, rev) => call('GET', `/documents/${id}/revisions/${rev}`),
    restoreRevision: (id, rev) => call('POST', `/documents/${id}/revisions/${rev}/restore`),
    getGlobal: () => call('GET', '/global'),
    saveGlobal: (body) => call('POST', '/global', body),
    render: (element, postId) => call('POST', '/render', { element, postId }),
    resolveDynamic: (postId, items) => call('POST', '/dynamic/resolve', { postId, items }),
    dynamicOptions: (tag, control, search = '', postId = 0) =>
      // `?rest_route=` REST bases (plain permalinks) already carry a query string.
      call('GET', `/dynamic/options${base.includes('?') ? '&' : '?'}${new URLSearchParams({ tag, control, search, postId: String(postId) }).toString()}`),
    templateOptions: ({ target, sub = '', search = '' }) =>
      call('GET', `/templates/options${base.includes('?') ? '&' : '?'}${new URLSearchParams({ target, sub, search }).toString()}`),
    saveTemplateConditions: (id, conditions) => call('PUT', `/templates/${id}/conditions`, { conditions }),
    asyncOptions: (route, refresh = false) =>
      call('GET', `/${route.replace(/^\/+/, '')}${refresh ? `${base.includes('?') ? '&' : '?'}refresh=1` : ''}`),
  };
}

let instance: ApiClient | null = null;

export function setApi(api: ApiClient): void {
  instance = api;
}

export async function getApi(): Promise<ApiClient> {
  if (instance) return instance;
  const config = window.ZADEX;
  if (!config) throw new Error('No config');
  // The dev harness mock is development-only: the production build drops this branch and its chunk.
  if (import.meta.env.DEV && config.restUrl.startsWith('mock:')) {
    const { createMockApi } = await import('@/dev/mock-api');
    instance = createMockApi(config);
  } else {
    instance = createRestApi(config);
  }
  return instance;
}
