import { writeFileSync, renameSync } from 'fs';
import { ensureUserFolder, resolveUserSubdir } from '../user-folder/paths.js';
import { logger } from '../logger.js';

// ── recordTo name validation ──────────────────────────────────────────────────
/** Only alphanumeric, dash, dot, underscore; no path separators, no '..' */
const RECORD_NAME_RE = /^[a-zA-Z0-9_.-]+$/;

function isValidRecordToName(s: string): boolean {
  return (
    typeof s === 'string' &&
    s.length > 0 &&
    s.length <= 128 &&
    RECORD_NAME_RE.test(s) &&
    !s.includes('..')
  );
}

/**
 * Truncate a string field to MAX_FIELD_BYTES if it exceeds the limit.
 * If truncated, append a notice with the original length.
 */
function truncateField(s: string | undefined): string | undefined {
  if (s === undefined) return undefined;
  const bytes = Buffer.byteLength(s, 'utf-8');
  if (bytes <= MAX_FIELD_BYTES) return s;
  // Truncate by chars, then verify byte length, then add notice
  let truncated = s;
  while (Buffer.byteLength(truncated, 'utf-8') > MAX_FIELD_BYTES - 50) {
    truncated = truncated.slice(0, -1);
  }
  const notice = `…[truncated from ${bytes} bytes]`;
  return truncated + notice;
}

// ── Buffer action cap ─────────────────────────────────────────────────────────
const MAX_BUFFER_ACTIONS = 5000;

/** Per-action string field cap: 8 KB. */
const MAX_FIELD_BYTES = 8_192;

/** taskIds for which we've already emitted the "buffer cap" warning. */
const warnedBufferCap = new Set<string>();

/**
 * One step of an iframe traversal from the main frame down to the target frame.
 *
 * `selector` is a CSS selector matched **inside the parent frame**.
 *   - Stable form: `iframe[name="..."]`, `iframe[id="..."]`, `iframe[src="..."]`
 *     when that attribute uniquely identifies the iframe within its parent.
 *   - Generic form: `'iframe'` (used together with `index` when no unique attr exists)
 *
 * `index` is the 0-based positional index among matching elements **in the parent
 * frame's direct child iframes**. Set when the selector alone is non-unique
 * (or when fallback positional lookup was used because frameElement() failed).
 *
 * Compiler maps these to Playwright FrameLocator chains:
 *   - `{ selector: 'iframe[name="x"]' }` → `.frameLocator('iframe[name="x"]')`
 *   - `{ selector: 'iframe', index: 0 }` → `.locator('iframe').nth(0).contentFrame()`
 */
export interface FrameChainEntry {
  selector: string;
  index?: number;
}

export interface RecordedAction {
  type: 'goto' | 'click' | 'fill' | 'screenshot' | 'wait' | 'getText' | 'dumpHtml';
  // The *resolved* selector (e.g. CSS path Playwright walked to from {e3} ref),
  // NOT the LLM-input ref. Optional — goto and wait don't have selectors.
  selector?: string;
  // Original ref from snapshot (for traceability). Not used at replay.
  originalRef?: string;
  value?: string;          // fill input or screenshot filename
  url?: string;            // goto target
  ms?: number;             // wait duration
  // Frame chain from outermost iframe to innermost. [] = main frame (no iframe).
  // Legacy: older recordings may have stored `string[]`; the compiler accepts both.
  frameChain?: FrameChainEntry[] | string[];
  // Wall-clock at action time, ISO string. For debugging.
  ts: string;
}

export interface BrowserRecorder {
  enable(taskId: string, recordTo: string): void;
  /** Returns null if recording is not enabled for this task. */
  recordTo(taskId: string): string | null;
  record(taskId: string, action: Omit<RecordedAction, 'ts'>): void;
  /** Flush to data/users/{ownerId}/recordings/{recordTo}.json and clear buffer.
   *  Returns the absolute path written, or null if nothing was buffered.  */
  flush(taskId: string, userFolderRoot: string, ownerId: string): string | null;
  /** Discard buffer (called on task abort). Idempotent. */
  cancel(taskId: string): void;
  /** Test helper. */
  bufferSize(taskId: string): number;
}

interface BufferEntry {
  recordTo: string;
  actions: RecordedAction[];
}

export function createBrowserRecorder(): BrowserRecorder {
  const buffers = new Map<string, BufferEntry>();

  return {
    enable(taskId, recordTo) {
      if (!isValidRecordToName(recordTo)) {
        logger.warn(
          `[recorder] enable: invalid recordTo name ${JSON.stringify(recordTo)} for task=${taskId} — recording disabled`
        );
        buffers.delete(taskId);
        warnedBufferCap.delete(taskId);
        return;
      }
      buffers.set(taskId, { recordTo, actions: [] });
    },

    recordTo(taskId) {
      return buffers.get(taskId)?.recordTo ?? null;
    },

    record(taskId, action) {
      const buf = buffers.get(taskId);
      if (!buf) return;
      if (buf.actions.length >= MAX_BUFFER_ACTIONS) {
        if (!warnedBufferCap.has(taskId)) {
          warnedBufferCap.add(taskId);
          logger.warn(
            `[recorder] task=${taskId} reached ${MAX_BUFFER_ACTIONS}-action cap; further actions dropped`
          );
        }
        return;
      }
      const sanitized: RecordedAction = {
        ...action,
        selector: truncateField(action.selector),
        value: truncateField(action.value),
        url: truncateField(action.url),
        originalRef: truncateField(action.originalRef),
        ts: new Date().toISOString(),
      };
      buf.actions.push(sanitized);
    },

    flush(taskId, userFolderRoot, ownerId) {
      const buf = buffers.get(taskId);
      if (!buf || buf.actions.length === 0) {
        buffers.delete(taskId);
        warnedBufferCap.delete(taskId);
        return null;
      }
      ensureUserFolder(userFolderRoot, ownerId);
      // resolveUserSubdir throws if recordTo contains path traversal sequences
      let target: string;
      try {
        target = resolveUserSubdir(userFolderRoot, ownerId, 'recordings', `${buf.recordTo}.json`);
      } catch (e) {
        logger.warn(`[recorder] flush: path traversal detected for task=${taskId} recordTo=${buf.recordTo}: ${(e as Error).message}`);
        buffers.delete(taskId);
        warnedBufferCap.delete(taskId);
        return null;
      }
      const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
      const data = {
        recordTo: buf.recordTo,
        capturedAt: new Date().toISOString(),
        actions: buf.actions,
      };
      writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
      renameSync(tmp, target);
      const recordTo = buf.recordTo;
      const actionCount = buf.actions.length;
      buffers.delete(taskId);
      warnedBufferCap.delete(taskId);
      logger.info(`[recorder] flush task=${taskId} recordTo=${recordTo} actions=${actionCount}`);
      return target;
    },

    cancel(taskId) {
      buffers.delete(taskId);
      warnedBufferCap.delete(taskId);
    },

    bufferSize(taskId) {
      return buffers.get(taskId)?.actions.length ?? 0;
    },
  };
}

export const recorder = createBrowserRecorder();
