export type ChatGptConversationState = 'loading' | 'ready' | 'generating' | 'idle' | 'error';
export type ChatGptWaitCondition = 'composer-ready' | 'conversation-id' | 'idle';

export type ChatGptIssueCode =
  | 'MESSAGE_DELIVERY_TIMEOUT'
  | 'RATE_LIMITED'
  | 'USAGE_LIMIT'
  | 'SAFETY_RESTRICTION'
  | 'CONFIRMATION_REQUIRED'
  | 'AUTH_REQUIRED'
  | 'NETWORK_ERROR'
  | 'PRODUCT_ERROR';

export interface ChatGptIssue {
  code: ChatGptIssueCode;
  detail: string;
  recoverable: boolean;
  retryAt?: string | null;
}

export interface ChatGptInspection {
  state: ChatGptConversationState;
  conversationId: string | null;
  url: string;
  composerReady: boolean;
  generating: boolean;
  error: string | null;
  issue: ChatGptIssue | null;
  assistantOutputTail: string | null;
  assistantOutputTruncated: boolean;
}

export interface ChatGptAdapterOptions {
  document?: Document;
  location?: Pick<Location, 'href' | 'pathname'>;
}

export interface ChatGptObserverOptions {
  reconcileIntervalMs?: number;
  mutationDebounceMs?: number;
}

export class HostUiChangedError extends Error {
  readonly code = 'HOST_UI_CHANGED' as const;

  constructor(message: string) {
    super(message);
    this.name = 'HostUiChangedError';
  }
}

export class ChatGptProductStateError extends Error {
  readonly code: ChatGptIssueCode;
  readonly recoverable: boolean;
  readonly retryAt: string | null;

  constructor(issue: ChatGptIssue) {
    super(issue.detail);
    this.name = 'ChatGptProductStateError';
    this.code = issue.code;
    this.recoverable = issue.recoverable;
    this.retryAt = issue.retryAt ?? null;
  }
}

const COMPOSER_SELECTORS = [
  '#prompt-textarea[contenteditable="true"]',
  '[data-testid="composer"] [contenteditable="true"]',
  'form [contenteditable="true"][role="textbox"]',
  'form textarea[name="prompt-textarea"]',
] as const;

const SEND_SELECTORS = [
  'button[data-testid="send-button"]',
  'form button[aria-label="Send prompt"]',
  'form button[aria-label^="Send"]',
] as const;

const STOP_SELECTORS = [
  'button[data-testid="stop-button"]',
  'button[aria-label="Stop generating"]',
  'button[aria-label^="Stop"]',
] as const;

// These selectors intentionally target semantic status/error surfaces only. Do not scan
// arbitrary transcript text: a user or assistant may legitimately mention these phrases.
const MAX_ASSISTANT_OUTPUT_TAIL_CHARS = 16_384;
const ASSISTANT_MESSAGE_SELECTOR = '[data-message-author-role=\"assistant\"]';

const ISSUE_SELECTORS = [
  '[data-testid="conversation-error"]',
  '[data-testid*="error"]',
  '[role="alert"]',
  '[role="status"]',
] as const;

const RATE_LIMIT_WORDS = /(?:\b(?:hit|reach|reached)\s+(?:your|the)\s+.{0,24}limit\b|you['’]?ve\s+(?:hit|reached)|usage\s+limit|message\s+limit|limit\s+(?:reached|resets?)|out\s+of\s+(?:messages|credits)|too\s+many\s+requests|rate\s*limit)/i;
const RESET_TIME = /\b(\d{1,2}):(\d{2})\s*(am|pm)?\b/i;
const RATE_LIMIT_ROOTS = ['form', '[role="dialog"]'] as const;

function firstElement<T extends Element>(document: Document, selectors: readonly string[]): T | null {
  for (const selector of selectors) {
    const element = document.querySelector<T>(selector);
    if (element) return element;
  }
  return null;
}

function currentDocument(explicit?: Document): Document {
  if (explicit) return explicit;
  if (typeof document === 'undefined') throw new HostUiChangedError('ChatGPT document is unavailable.');
  return document;
}

function currentLocation(explicit?: Pick<Location, 'href' | 'pathname'>): Pick<Location, 'href' | 'pathname'> {
  if (explicit) return explicit;
  if (typeof location === 'undefined') return { href: 'https://chatgpt.com/', pathname: '/' } as Pick<Location, 'href' | 'pathname'>;
  return location;
}

export function conversationIdFromPath(pathname: string): string | null {
  const match = pathname.match(/(?:^|\/)c\/([^/?#]+)/);
  return match?.[1] ? decodeURIComponent(match[1]) : null;
}

function isDisabled(element: Element | null): boolean {
  if (!element) return false;
  return element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true';
}

export function parseRateLimitReset(text: string, now = new Date()): string | null {
  const match = RESET_TIME.exec(text);
  if (!match) return null;
  let hour = Number.parseInt(match[1] ?? '', 10);
  const minute = Number.parseInt(match[2] ?? '', 10);
  const meridiem = (match[3] ?? '').toLowerCase();
  if (meridiem === 'pm' && hour < 12) hour += 12;
  else if (meridiem === 'am' && hour === 12) hour = 0;
  if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
  const resume = new Date(now.getTime());
  resume.setHours(hour, minute, 0, 0);
  if (resume.getTime() <= now.getTime()) resume.setDate(resume.getDate() + 1);
  return resume.toISOString();
}

function normalizedText(element: Element): string {
  const htmlElement = element as HTMLElement;
  return (htmlElement.innerText ?? element.textContent ?? '').replace(/\s+/g, ' ').trim();
}

function classifyIssueText(text: string): ChatGptIssue | null {
  const compact = text.slice(0, 500);
  if (/message delivery timed out(?:\.|\s)*please try again/i.test(compact)) {
    return { code: 'MESSAGE_DELIVERY_TIMEOUT', detail: compact, recoverable: true };
  }
  if (/rate limit|too many requests|requests? too quickly|try again in \d+/i.test(compact)) {
    return { code: 'RATE_LIMITED', detail: compact, recoverable: false, retryAt: parseRateLimitReset(compact) };
  }
  if (/usage limit|reached (?:your|the) limit|maximum (?:messages?|usage)|limit resets?|out of (?:messages|credits)|you['’]?ve (?:hit|reached)/i.test(compact)) {
    return { code: 'USAGE_LIMIT', detail: compact, recoverable: false, retryAt: parseRateLimitReset(compact) };
  }
  if (/safety (?:policy|restriction)|policy restriction|request (?:was )?blocked|content (?:was )?blocked/i.test(compact)) {
    return { code: 'SAFETY_RESTRICTION', detail: compact, recoverable: false };
  }
  if (/confirm (?:to continue|this action)|confirmation required|verify (?:this|your)|verification required/i.test(compact)) {
    return { code: 'CONFIRMATION_REQUIRED', detail: compact, recoverable: false };
  }
  if (/sign in|log in|session expired|authentication required/i.test(compact)) {
    return { code: 'AUTH_REQUIRED', detail: compact, recoverable: false };
  }
  if (/network error|connection (?:lost|failed)|disconnected|offline/i.test(compact)) {
    return { code: 'NETWORK_ERROR', detail: compact, recoverable: true };
  }
  if (/something went wrong|error|failed|try again/i.test(compact)) {
    return { code: 'PRODUCT_ERROR', detail: compact, recoverable: false };
  }
  return null;
}

export function latestAssistantOutput(document: Document): { tail: string | null; truncated: boolean } {
  const elements = document.querySelectorAll<HTMLElement>(ASSISTANT_MESSAGE_SELECTOR);
  const latest = elements.item(elements.length - 1);
  if (!latest) return { tail: null, truncated: false };
  const text = ((latest as HTMLElement).innerText ?? latest.textContent ?? '').trim();
  if (!text) return { tail: null, truncated: false };
  if (text.length <= MAX_ASSISTANT_OUTPUT_TAIL_CHARS) return { tail: text, truncated: false };
  return { tail: text.slice(-MAX_ASSISTANT_OUTPUT_TAIL_CHARS), truncated: true };
}

export function detectChatGptIssue(document: Document): ChatGptIssue | null {
  const rateSeen = new Set<Element>();
  const rateRoots = RATE_LIMIT_ROOTS.flatMap((selector) => Array.from(document.querySelectorAll<HTMLElement>(selector)));
  const form = document.querySelector<HTMLElement>('form');
  if (form?.parentElement) rateRoots.unshift(form.parentElement);
  for (const root of rateRoots) {
    for (const element of Array.from(root.querySelectorAll<HTMLElement>('div,span,p'))) {
      if (rateSeen.has(element) || element.closest('[data-message-author-role]')) continue;
      rateSeen.add(element);
      const text = normalizedText(element);
      if (!text || text.length > 400 || element.children.length > 3 || !RATE_LIMIT_WORDS.test(text)) continue;
      const issue = classifyIssueText(text);
      if (issue?.code === 'RATE_LIMITED' || issue?.code === 'USAGE_LIMIT') return issue;
    }
  }
  const seen = new Set<Element>();
  for (const selector of ISSUE_SELECTORS) {
    for (const element of document.querySelectorAll(selector)) {
      if (seen.has(element)) continue;
      seen.add(element);
      const text = normalizedText(element);
      if (!text) continue;
      const issue = classifyIssueText(text);
      if (issue) return issue;
    }
  }
  return null;
}

function inspectionSignature(inspection: ChatGptInspection): string {
  return JSON.stringify([
    inspection.state,
    inspection.conversationId,
    inspection.composerReady,
    inspection.generating,
    inspection.issue?.code ?? null,
    inspection.issue?.retryAt ?? null,
    inspection.error,
    inspection.url,
    inspection.assistantOutputTail,
    inspection.assistantOutputTruncated,
  ]);
}

function issueBlocksSubmission(issue: ChatGptIssue | null): boolean {
  return issue !== null && issue.code !== 'MESSAGE_DELIVERY_TIMEOUT';
}

export class ChatGptWebAdapter {
  private readonly document: Document;
  private readonly location: Pick<Location, 'href' | 'pathname'>;

  constructor(options: ChatGptAdapterOptions = {}) {
    this.document = currentDocument(options.document);
    this.location = currentLocation(options.location);
  }

  inspect(): ChatGptInspection {
    const composer = firstElement<HTMLElement>(this.document, COMPOSER_SELECTORS);
    const stop = firstElement<HTMLButtonElement>(this.document, STOP_SELECTORS);
    const issue = detectChatGptIssue(this.document);
    const conversationId = conversationIdFromPath(this.location.pathname);
    let state: ChatGptConversationState;
    if (issue) state = 'error';
    else if (stop) state = 'generating';
    else if (composer && conversationId) state = 'idle';
    else if (composer) state = 'ready';
    else state = 'loading';

    const assistantOutput = state === 'idle' ? latestAssistantOutput(this.document) : { tail: null, truncated: false };
    return {
      state,
      conversationId,
      url: this.location.href,
      composerReady: Boolean(composer),
      generating: Boolean(stop),
      error: issue?.detail ?? null,
      issue,
      assistantOutputTail: assistantOutput.tail,
      assistantOutputTruncated: assistantOutput.truncated,
    };
  }

  observe(onChange: (inspection: ChatGptInspection) => void, options: ChatGptObserverOptions = {}): () => void {
    const view = this.document.defaultView;
    const Observer = view?.MutationObserver ?? globalThis.MutationObserver;
    if (!Observer) throw new HostUiChangedError('MutationObserver is unavailable in the ChatGPT page context.');

    const intervalMs = Math.max(500, Math.min(options.reconcileIntervalMs ?? 2_000, 30_000));
    const mutationDebounceMs = Math.max(0, Math.min(options.mutationDebounceMs ?? 250, 2_000));
    let disposed = false;
    let lastSignature: string | null = null;
    let mutationTimer: ReturnType<typeof setTimeout> | null = null;
    const publish = (): void => {
      if (disposed) return;
      const inspection = this.inspect();
      const signature = inspectionSignature(inspection);
      if (signature === lastSignature) return;
      lastSignature = signature;
      onChange(inspection);
    };
    const schedulePublish = (): void => {
      if (disposed) return;
      if (mutationDebounceMs === 0) {
        publish();
        return;
      }
      if (mutationTimer) return;
      mutationTimer = setTimeout(() => {
        mutationTimer = null;
        publish();
      }, mutationDebounceMs);
    };

    const observer = new Observer(schedulePublish);
    observer.observe(this.document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
    view?.addEventListener('popstate', schedulePublish);
    view?.addEventListener('hashchange', schedulePublish);
    const timer = setInterval(publish, intervalMs);
    publish();

    return () => {
      if (disposed) return;
      disposed = true;
      observer.disconnect();
      clearInterval(timer);
      if (mutationTimer) clearTimeout(mutationTimer);
      mutationTimer = null;
      view?.removeEventListener('popstate', schedulePublish);
      view?.removeEventListener('hashchange', schedulePublish);
    };
  }

  async waitFor(condition: ChatGptWaitCondition, timeoutMs: number): Promise<ChatGptInspection> {
    const boundedTimeout = Math.max(1, Math.min(timeoutMs, 60_000));
    const matches = (inspection: ChatGptInspection): boolean => {
      if (issueBlocksSubmission(inspection.issue)) throw new ChatGptProductStateError(inspection.issue!);
      switch (condition) {
        case 'composer-ready': return inspection.composerReady;
        case 'conversation-id': return inspection.conversationId !== null;
        case 'idle': return inspection.composerReady && !inspection.generating;
      }
    };

    const immediate = this.inspect();
    if (matches(immediate)) return immediate;

    return new Promise<ChatGptInspection>((resolve, reject) => {
      let settled = false;
      let disposeObserver: (() => void) | null = null;
      const timer = setTimeout(() => {
        finish(() => reject(new HostUiChangedError(`Timed out after ${boundedTimeout}ms waiting for ChatGPT ${condition}.`)));
      }, boundedTimeout);
      const finish = (body: () => void): void => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        disposeObserver?.();
        body();
      };
      try {
        disposeObserver = this.observe((inspection) => {
          try {
            if (matches(inspection)) finish(() => resolve(inspection));
          } catch (error) {
            finish(() => reject(error));
          }
        }, { reconcileIntervalMs: Math.min(2_000, boundedTimeout), mutationDebounceMs: 0 });
      } catch (error) {
        finish(() => reject(error));
      }
    });
  }

  async submitPrompt(prompt: string): Promise<void> {
    await this.submitThroughComposer(prompt, 'idle');
  }

  async submitSteering(prompt: string): Promise<void> {
    await this.submitThroughComposer(prompt, 'generating');
  }

  private async submitThroughComposer(prompt: string, requiredState: 'idle' | 'generating'): Promise<void> {
    if (!prompt.trim()) throw new HostUiChangedError('Refusing to submit an empty ChatGPT prompt.');
    const inspection = this.inspect();
    if (issueBlocksSubmission(inspection.issue)) throw new ChatGptProductStateError(inspection.issue!);
    if (requiredState === 'idle' && inspection.generating) {
      throw new HostUiChangedError('ChatGPT is already generating; ordinary prompt submission must wait for idle.');
    }
    if (requiredState === 'generating' && !inspection.generating) {
      throw new HostUiChangedError('ChatGPT is not generating; mid-turn steering requires an active generation.');
    }

    const composer = firstElement<HTMLElement>(this.document, COMPOSER_SELECTORS);
    if (!composer) throw new HostUiChangedError('ChatGPT composer could not be located using known semantic selectors.');

    this.writeComposer(composer, prompt);
    const send = await this.waitForSendEnabled(2_000);
    send.click();
    if (requiredState === 'generating') await this.waitForComposerAccepted(composer, prompt, 2_000);
  }

  private writeComposer(composer: HTMLElement, prompt: string): void {
    composer.focus();
    const view = this.document.defaultView;
    const InputEventCtor = view?.InputEvent ?? globalThis.InputEvent;
    const emitInput = (): void => {
      if (typeof InputEventCtor === 'function') {
        composer.dispatchEvent(new InputEventCtor('input', { bubbles: true, inputType: 'insertText', data: prompt }));
      } else {
        composer.dispatchEvent(new Event('input', { bubbles: true }));
      }
    };

    if (composer instanceof HTMLTextAreaElement) {
      const TextArea = view?.HTMLTextAreaElement ?? globalThis.HTMLTextAreaElement;
      const setter = TextArea ? Object.getOwnPropertyDescriptor(TextArea.prototype, 'value')?.set : undefined;
      if (setter) setter.call(composer, prompt);
      else composer.value = prompt;
      emitInput();
      return;
    }

    const selection = view?.getSelection?.();
    const range = this.document.createRange?.();
    if (selection && range) {
      range.selectNodeContents(composer);
      selection.removeAllRanges();
      selection.addRange(range);
    }

    const editableDocument = this.document as Document & { execCommand?: (command: string, showUi?: boolean, value?: string) => boolean };
    let inserted = false;
    if (typeof editableDocument.execCommand === 'function') {
      try { inserted = editableDocument.execCommand('insertText', false, prompt); } catch { inserted = false; }
    }
    if (!inserted) {
      composer.replaceChildren(this.document.createTextNode(prompt));
      emitInput();
    }
  }

  private async waitForComposerAccepted(composer: HTMLElement, submittedPrompt: string, timeoutMs: number): Promise<void> {
    const text = (): string => {
      if (composer instanceof HTMLTextAreaElement) return composer.value.trim();
      return (composer.textContent ?? '').trim();
    };
    if (!text() || text() !== submittedPrompt.trim()) return;

    const view = this.document.defaultView;
    const Observer = view?.MutationObserver ?? globalThis.MutationObserver;
    if (!Observer) throw new HostUiChangedError('Unable to prove ChatGPT accepted the steering message.');

    await new Promise<void>((resolve, reject) => {
      let settled = false;
      const finish = (fn: () => void): void => {
        if (settled) return;
        settled = true;
        observer.disconnect();
        clearTimeout(timer);
        fn();
      };
      const check = (): void => {
        if (!text() || text() !== submittedPrompt.trim()) finish(resolve);
      };
      const observer = new Observer(check);
      observer.observe(composer, { childList: true, subtree: true, characterData: true, attributes: true });
      const timer = setTimeout(() => finish(() => reject(new HostUiChangedError('ChatGPT steering click was not observably accepted; refusing to assume delivery.'))), timeoutMs);
      check();
    });
  }

  private async waitForSendEnabled(timeoutMs: number): Promise<HTMLButtonElement> {
    const current = (): HTMLButtonElement | null => {
      const send = firstElement<HTMLButtonElement>(this.document, SEND_SELECTORS);
      return send && !isDisabled(send) ? send : null;
    };
    const immediate = current();
    if (immediate) return immediate;

    const view = this.document.defaultView;
    const Observer = view?.MutationObserver ?? globalThis.MutationObserver;
    if (!Observer) throw new HostUiChangedError('ChatGPT send control remained disabled after composer input.');

    return new Promise<HTMLButtonElement>((resolve, reject) => {
      let settled = false;
      const finish = (fn: () => void): void => {
        if (settled) return;
        settled = true;
        observer.disconnect();
        clearTimeout(timer);
        fn();
      };
      const check = (): void => {
        const send = current();
        if (send) finish(() => resolve(send));
      };
      const observer = new Observer(check);
      observer.observe(this.document.documentElement, { childList: true, subtree: true, attributes: true });
      const timer = setTimeout(() => finish(() => reject(new HostUiChangedError('ChatGPT send control remained disabled after composer input.'))), timeoutMs);
      check();
    });
  }
}
