/**
 * Console Capture Tool - Capture and manage browser console logs
 *
 * Uses CDP Runtime.consoleAPICalled + Runtime.exceptionThrown directly
 * instead of Puppeteer's page.on('console'), because rebrowser-puppeteer-core
 * skips Runtime.enable to avoid bot detection. We enable it on a dedicated
 * CDPSession so console events are reliably captured.
 */

import { CDPSession } from 'puppeteer-core';
import * as crypto from 'crypto';
import { MCPServer } from '../mcp-server';
import { TOOL_ANNOTATIONS } from '../types/tool-annotations';
import { MCPToolDefinition, MCPResult, ToolHandler } from '../types/mcp';
import { getSessionManager } from '../session-manager';
import { createConsoleRingBuffer, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from '../core/console-buffer/ring-buffer';
import type { ConsoleRingBuffer, ConsoleRingBufferStats } from '../core/console-buffer/types';
import { paginate } from '../utils/paginate';
import { areBoundaryMarkersEnabled, wrapBoundaryMarker } from '../core/perception/boundary-markers';

/**
 * Validate caller-supplied cap values. Used to reject `maxLogs`/`maxBytes`
 * inputs that would corrupt the ring buffer (zero, negative, NaN, Infinity,
 * non-integer, non-number) before they reach `createConsoleRingBuffer`.
 *
 * The factory itself also coerces invalid values back to defaults as a
 * defense-in-depth measure, but the tool-level check returns a clear
 * structured error to the caller so they see the bad input.
 */
function isPositiveInteger(v: unknown): boolean {
  if (typeof v !== 'number') return false;
  return Number.isFinite(v) && Number.isInteger(v) && v > 0;
}

// Console log entry structure
interface ConsoleLogEntry {
  type: string;
  text: string;
  timestamp: number;
  location?: {
    url?: string;
    lineNumber?: number;
    columnNumber?: number;
  };
  args?: string[];
  truncatedFrom?: number;
}

// CDP event payload types
interface CDPConsoleAPICalledEvent {
  type: string;
  args: Array<{ type: string; value?: unknown; description?: string; preview?: { properties?: Array<{ value?: string }> } }>;
  executionContextId: number;
  timestamp: number;
  stackTrace?: {
    callFrames: Array<{
      url: string;
      lineNumber: number;
      columnNumber: number;
    }>;
  };
}

interface CDPExceptionThrownEvent {
  timestamp: number;
  exceptionDetails: {
    text: string;
    exception?: { description?: string; value?: unknown };
    lineNumber?: number;
    columnNumber?: number;
    url?: string;
    stackTrace?: {
      callFrames: Array<{
        url: string;
        lineNumber: number;
        columnNumber: number;
      }>;
    };
  };
}

// Capture state for each tab
interface CaptureState {
  logs: ConsoleRingBuffer<ConsoleLogEntry>;
  cdpSession: CDPSession;
  consoleHandler: (event: CDPConsoleAPICalledEvent) => void;
  exceptionHandler: (event: CDPExceptionThrownEvent) => void;
  startedAt: number;
  filter?: string[];
  maxLogs: number;
  maxBytes: number;
}

// Module-level state storage
const captureStates: Map<string, CaptureState> = new Map();

// Deduplicated log entry (returned in get responses)
interface DedupedLogEntry {
  type: string;
  text: string;
  count: number;
  firstTimestamp?: number;
  lastTimestamp?: number;
  location?: {
    url?: string;
    lineNumber?: number;
    columnNumber?: number;
  };
  args?: string[];
  truncatedFrom?: number;
}

/**
 * Collapse consecutive identical log messages into single entries with a count.
 * Error and warning types are NEVER deduplicated — always shown individually.
 * Only groups of 3+ identical consecutive messages are collapsed.
 */
function deduplicateLogs(logs: ConsoleLogEntry[]): DedupedLogEntry[] {
  const result: DedupedLogEntry[] = [];
  let i = 0;
  while (i < logs.length) {
    const current = logs[i];

    // NEVER deduplicate error or warning types — always show individually
    if (current.type === 'error' || current.type === 'warning') {
      result.push({
        type: current.type,
        text: current.text,
        count: 1,
        firstTimestamp: current.timestamp,
        lastTimestamp: current.timestamp,
        location: current.location,
        args: current.args,
        ...(current.truncatedFrom !== undefined && { truncatedFrom: current.truncatedFrom }),
      });
      i++;
      continue;
    }

    // Count consecutive identical messages (same text AND same type)
    let count = 1;
    while (
      i + count < logs.length &&
      logs[i + count].text === current.text &&
      logs[i + count].type === current.type
    ) {
      count++;
    }

    if (count >= 3) {
      // Collapse into single entry with count
      result.push({
        text: current.text,
        type: current.type,
        count,
        firstTimestamp: current.timestamp,
        lastTimestamp: logs[i + count - 1].timestamp,
        location: current.location,
        args: current.args,
      });
    } else {
      // Show individually
      for (let j = 0; j < count; j++) {
        const entry = logs[i + j];
        result.push({
          type: entry.type,
          text: entry.text,
          count: 1,
          firstTimestamp: entry.timestamp,
          lastTimestamp: entry.timestamp,
          location: entry.location,
          args: entry.args,
          ...(entry.truncatedFrom !== undefined && { truncatedFrom: entry.truncatedFrom }),
        });
      }
    }
    i += count;
  }
  return result;
}

const definition: MCPToolDefinition = {
  name: 'console_capture',
  description: 'Capture browser console output (start, stop, get, clear).',
  annotations: TOOL_ANNOTATIONS.console_capture,
  inputSchema: {
    type: 'object',
    properties: {
      tabId: {
        type: 'string',
        description: 'Tab ID',
      },
      action: {
        type: 'string',
        enum: ['start', 'stop', 'get', 'clear'],
        description: 'Action to perform',
      },
      filter: {
        type: 'array',
        items: { type: 'string' },
        description: 'Log types to capture. Default: all',
      },
      limit: {
        type: 'number',
        description: 'Max logs to return (get action)',
      },
      cursor: {
        type: 'string',
        description: 'Opaque pagination cursor returned as nextCursor from a prior console_capture get call.',
      },
      maxLogs: {
        type: 'number',
        description: 'Max logs to store. Default: 1000',
      },
      maxBytes: {
        type: 'number',
        description: 'Max total bytes of logs to store. Default: 4194304 (4 MiB)',
      },
      boundaryMarkers: {
        type: 'boolean',
        description: 'Wrap console-origin text in <oc:console>. Default true; false disables.',
      },
    },
    required: ['tabId', 'action'],
  },
};

// Cleanup listener when session ends
const setupCleanupListener = (() => {
  let initialized = false;
  return () => {
    if (initialized) return;
    initialized = true;

    const sessionManager = getSessionManager();
    sessionManager.addEventListener((event) => {
      if (
        event.type === 'session:target-closed' ||
        event.type === 'session:target-removed'
      ) {
        const targetId = event.targetId;
        if (targetId) {
          const state = captureStates.get(targetId);
          if (state) {
            state.cdpSession.detach().catch(() => {});
            captureStates.delete(targetId);
            console.error(`[ConsoleCapture] Cleaned up capture state for closed tab ${targetId}`);
          }
        }
      }
    });
  };
})();

/** Build a placeholder entry for an oversized log entry. */
function makeOversizedPlaceholder(originalSizeBytes: number): ConsoleLogEntry {
  return {
    type: 'log',
    text: '[entry exceeded maxBytes — truncated]',
    timestamp: Date.now(),
    truncatedFrom: originalSizeBytes,
  };
}

const handler: ToolHandler = async (
  sessionId: string,
  args: Record<string, unknown>
): Promise<MCPResult> => {
  const tabId = args.tabId as string;
  const action = args.action as string;
  const filter = args.filter as string[] | undefined;
  const limit = args.limit as number | undefined;
  const cursor = typeof args.cursor === 'string' ? args.cursor : undefined;
  // maxLogs is a backward-compatible alias for maxLines. Defense-in-depth:
  // reject zero / non-positive / non-integer values BEFORE forwarding to
  // createConsoleRingBuffer so a malformed `start` request returns a clear
  // error to the caller instead of silently flipping to the default
  // (Codex P1 — `maxLogs: 0` is currently in the schema's value range).
  const rawMaxLogs = args.maxLogs as unknown;
  const rawMaxBytes = args.maxBytes as unknown;
  const maxLogsValid = rawMaxLogs === undefined || isPositiveInteger(rawMaxLogs);
  const maxBytesValid = rawMaxBytes === undefined || isPositiveInteger(rawMaxBytes);
  if (!maxLogsValid || !maxBytesValid) {
    return {
      content: [{
        type: 'text',
        text:
          'Error: maxLogs and maxBytes must be positive integers when supplied. ' +
          `Received maxLogs=${JSON.stringify(rawMaxLogs)}, maxBytes=${JSON.stringify(rawMaxBytes)}.`,
      }],
      isError: true,
    };
  }
  const maxLogs = (rawMaxLogs as number | undefined) ?? DEFAULT_MAX_LINES;
  const maxBytes = (rawMaxBytes as number | undefined) ?? DEFAULT_MAX_BYTES;
  const boundaryMarkers = areBoundaryMarkersEnabled(args);

  const sessionManager = getSessionManager();

  // Setup cleanup listener on first use
  setupCleanupListener();

  if (!tabId) {
    return {
      content: [{ type: 'text', text: 'Error: tabId is required' }],
      isError: true,
    };
  }

  if (!action) {
    return {
      content: [{ type: 'text', text: 'Error: action is required' }],
      isError: true,
    };
  }

  try {
    const page = await sessionManager.getPage(sessionId, tabId, undefined, 'console_capture');
    if (!page) {
      return {
        content: [{ type: 'text', text: `Error: Tab ${tabId} not found` }],
        isError: true,
      };
    }

    switch (action) {
      case 'start': {
        // Check if already capturing
        if (captureStates.has(tabId)) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  action: 'start',
                  status: 'already_capturing',
                  message: 'Console capture already running for this tab',
                }),
              },
            ],
          };
        }

        // Create a dedicated CDP session and enable Runtime domain
        // so we receive consoleAPICalled and exceptionThrown events.
        // rebrowser-puppeteer-core skips Runtime.enable by default,
        // so Puppeteer's page.on('console') never fires.
        const cdpSession = await page.createCDPSession();
        await cdpSession.send('Runtime.enable');

        const ringBuffer = createConsoleRingBuffer<ConsoleLogEntry>(
          { maxLines: maxLogs, maxBytes },
          makeOversizedPlaceholder,
        );

        const state: CaptureState = {
          logs: ringBuffer,
          cdpSession,
          consoleHandler: () => {},
          exceptionHandler: () => {},
          startedAt: Date.now(),
          filter,
          maxLogs,
          maxBytes,
        };

        // Map CDP console types to Puppeteer-style types
        const mapType = (cdpType: string): string => {
          // CDP uses 'warning' but Puppeteer normalizes to 'warn' in some versions
          if (cdpType === 'warning') return 'warning';
          return cdpType;
        };

        state.consoleHandler = (event: CDPConsoleAPICalledEvent) => {
          const logType = mapType(event.type);

          // Apply filter if specified
          if (filter && filter.length > 0 && !filter.includes(logType)) {
            return;
          }

          const callFrame = event.stackTrace?.callFrames?.[0];
          const text = event.args
            .map((arg) => {
              if (arg.value !== undefined) return String(arg.value);
              if (arg.description) return arg.description;
              return `[${arg.type}]`;
            })
            .join(' ');

          const entry: ConsoleLogEntry = {
            type: logType,
            text,
            timestamp: Date.now(),
            location: callFrame
              ? {
                  url: callFrame.url,
                  lineNumber: callFrame.lineNumber,
                  columnNumber: callFrame.columnNumber,
                }
              : undefined,
            args: event.args.map((arg) => {
              if (arg.value !== undefined) return String(arg.value);
              if (arg.description) return arg.description;
              return `[${arg.type}]`;
            }),
          };

          const sizeBytes = JSON.stringify(entry).length;
          state.logs.push(entry, sizeBytes);
        };

        // Capture unhandled promise rejections
        state.exceptionHandler = (event: CDPExceptionThrownEvent) => {
          const details = event.exceptionDetails;

          // Apply filter — map exceptions to 'error' type
          if (filter && filter.length > 0 && !filter.includes('error')) {
            return;
          }

          const text =
            details.exception?.description ||
            details.exception?.value?.toString() ||
            details.text ||
            'Unknown error';
          const callFrame = details.stackTrace?.callFrames?.[0];

          const entry: ConsoleLogEntry = {
            type: 'error',
            text,
            timestamp: Date.now(),
            location: callFrame
              ? {
                  url: callFrame.url,
                  lineNumber: callFrame.lineNumber,
                  columnNumber: callFrame.columnNumber,
                }
              : details.url
                ? { url: details.url, lineNumber: details.lineNumber, columnNumber: details.columnNumber }
                : undefined,
          };

          const sizeBytes = JSON.stringify(entry).length;
          state.logs.push(entry, sizeBytes);
        };

        cdpSession.on('Runtime.consoleAPICalled', state.consoleHandler as any);
        cdpSession.on('Runtime.exceptionThrown', state.exceptionHandler as any);
        captureStates.set(tabId, state);

        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({
                action: 'start',
                status: 'started',
                filter: filter || 'all',
                maxLogs,
                message: `Console capture started for tab ${tabId}`,
              }),
            },
          ],
        };
      }

      case 'stop': {
        const state = captureStates.get(tabId);
        if (!state) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  action: 'stop',
                  status: 'not_running',
                  message: 'Console capture was not running for this tab',
                }),
              },
            ],
          };
        }

        // Remove CDP listeners and detach session
        state.cdpSession.off('Runtime.consoleAPICalled', state.consoleHandler as any);
        state.cdpSession.off('Runtime.exceptionThrown', state.exceptionHandler as any);
        await state.cdpSession.send('Runtime.disable').catch(() => {});
        await state.cdpSession.detach().catch(() => {});
        const logCount = state.logs.stats().retained;
        const duration = Date.now() - state.startedAt;
        captureStates.delete(tabId);

        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({
                action: 'stop',
                status: 'stopped',
                capturedLogs: logCount,
                durationMs: duration,
                message: `Console capture stopped. Captured ${logCount} logs over ${Math.round(duration / 1000)}s`,
              }),
            },
          ],
        };
      }

      case 'get': {
        const state = captureStates.get(tabId);
        if (!state) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  action: 'get',
                  status: 'not_running',
                  logs: [],
                  message: 'Console capture is not running for this tab',
                }),
              },
            ],
          };
        }

        const logs: ConsoleLogEntry[] = (limit && limit > 0)
          ? state.logs.tail(limit)
          : state.logs.drain();

        // Deduplicate consecutive identical log messages
        const deduplicatedLogs = deduplicateLogs(logs).map((log) => ({
          ...log,
          text: boundaryMarkers ? wrapBoundaryMarker('console', { origin: log.location?.url || page.url() }, log.text) : log.text,
          args: boundaryMarkers && log.args ? log.args.map((arg) => wrapBoundaryMarker('console', { origin: log.location?.url || page.url() }, arg)) : log.args,
        }));

        const bufStats = state.logs.stats();

        // Calculate stats
        const stats = {
          total: bufStats.retained,
          returned: deduplicatedLogs.length,
          beforeDedup: logs.length,
          byType: {} as Record<string, number>,
        };
        for (const log of state.logs.drain()) {
          stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
        }

        const bufferStats: ConsoleRingBufferStats = bufStats;
        const pagination = buildConsoleCapturePagination(deduplicatedLogs, {
          cursor,
          pageSize: cursor ? Math.max(1, Math.floor(limit ?? 200)) : Math.max(1, Math.floor(limit ?? Math.max(deduplicatedLogs.length, 1))),
        });
        if (pagination.staleCursor) return staleCursorResult();
        if (pagination.invalidCursor) return invalidCursorResult(pagination.invalidCursor);
        if (cursor) stats.returned = pagination.logs.length;

        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({
                action: 'get',
                status: 'running',
                logs: cursor ? pagination.logs : deduplicatedLogs,
                stats,
                durationMs: Date.now() - state.startedAt,
                bufferStats,
                ...(cursor ? { hasMore: pagination.hasMore } : {}),
                ...(pagination.nextCursor ? { nextCursor: pagination.nextCursor } : {}),
              }),
            },
          ],
          structuredContent: {
            action: 'get',
            status: 'running',
            entries: cursor ? pagination.logs : deduplicatedLogs,
            hasMore: cursor ? pagination.hasMore : false,
            ...(pagination.nextCursor ? { nextCursor: pagination.nextCursor } : {}),
            total: pagination.total,
          },
        };
      }

      case 'clear': {
        const state = captureStates.get(tabId);
        if (!state) {
          return {
            content: [
              {
                type: 'text',
                text: JSON.stringify({
                  action: 'clear',
                  status: 'not_running',
                  message: 'Console capture is not running for this tab',
                }),
              },
            ],
          };
        }

        const clearedCount = state.logs.stats().retained;
        state.logs.clear();

        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({
                action: 'clear',
                status: 'cleared',
                clearedCount,
                message: `Cleared ${clearedCount} logs`,
              }),
            },
          ],
        };
      }

      default:
        return {
          content: [
            {
              type: 'text',
              text: `Error: Unknown action "${action}". Use: start, stop, get, or clear`,
            },
          ],
          isError: true,
        };
    }
  } catch (error) {
    return {
      content: [
        {
          type: 'text',
          text: `Console capture error: ${error instanceof Error ? error.message : String(error)}`,
        },
      ],
      isError: true,
    };
  }
};

export function buildConsoleCapturePagination(
  logs: DedupedLogEntry[],
  opts: { cursor?: string; pageSize: number },
): {
  logs: DedupedLogEntry[];
  hasMore: boolean;
  total: number;
  nextCursor?: string;
  staleCursor?: true;
  invalidCursor?: unknown;
} {
  try {
    const page = paginate(logs, {
      cursor: opts.cursor,
      pageSize: opts.pageSize,
      contentHash: hashConsoleEntries(logs),
    });
    if (page.staleCursor) return { logs: [], hasMore: false, total: page.total, staleCursor: true };
    return {
      logs: page.items,
      hasMore: page.hasMore,
      total: page.total,
      ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
    };
  } catch (error) {
    return { logs: [], hasMore: false, total: logs.length, invalidCursor: error };
  }
}

function hashConsoleEntries(logs: DedupedLogEntry[]): string {
  const hash = crypto.createHash('sha256');
  for (const log of logs) {
    hash
      .update(log.type)
      .update('\0')
      .update(log.text)
      .update('\0')
      .update(String(log.firstTimestamp ?? ''))
      .update('\0')
      .update(String(log.lastTimestamp ?? ''))
      .update('\0')
      .update(String(log.count ?? 1))
      .update('\0');
  }
  return hash.digest('hex');
}

function invalidCursorResult(error: unknown): MCPResult {
  const message = error instanceof Error ? error.message : String(error);
  return {
    isError: true,
    content: [{ type: 'text', text: JSON.stringify({ error: { code: 'invalid_cursor', message } }) }],
    structuredContent: { error: { code: 'invalid_cursor', message } },
  };
}

function staleCursorResult(): MCPResult {
  return {
    isError: true,
    content: [{ type: 'text', text: JSON.stringify({ error: { code: 'stale_cursor', retry: 'restart_from_no_cursor' } }) }],
    structuredContent: { error: { code: 'stale_cursor', retry: 'restart_from_no_cursor' } },
  };
}

export function registerConsoleCaptureTool(server: MCPServer): void {
  server.registerTool('console_capture', handler, definition);
}

// Export captureStates for watchdog integration (read-only access to stats).
export { captureStates };
export type { CaptureState };
