/**
 * Main DiagnosticsClient — singleton for remote diagnostics collection
 *
 * @module client
 */

import type {
  DiagnosticsConfig,
  DiagnosticsSession,
  ClientState,
  LogLevel,
  TraceSpan,
  LogEntry,
  Breadcrumb,
  NetworkRequest,
  DeviceState,
} from './types.js';
import { BreadcrumbTrail } from './breadcrumbs.js';
import { NetworkInterceptor } from './network.js';
import { collectDeviceState } from './device.js';

// DOM type declarations for ESLint
type ErrorEvent = {
  message: string;
  filename: string;
  lineno: number;
  colno: number;
  error?: { stack?: string };
};

export interface DiagnosticsClientOptions extends DiagnosticsConfig {
  /** Custom logger */
  logger?: {
    debug: (msg: string, meta?: Record<string, unknown>) => void;
    info: (msg: string, meta?: Record<string, unknown>) => void;
    warn: (msg: string, meta?: Record<string, unknown>) => void;
    error: (msg: string, meta?: Record<string, unknown>) => void;
  };
}

/**
 * Diagnostics client for remote debug session collection
 */
export class DiagnosticsClient {
  private static instance: DiagnosticsClient | null = null;

  private config: DiagnosticsClientOptions & {
    pollIntervalMs: number;
    maxBreadcrumbs: number;
    captureConsole: boolean;
    captureErrors: boolean;
    captureNetwork: boolean;
    networkExcludePatterns: RegExp[];
    logger: NonNullable<DiagnosticsClientOptions['logger']>;
  };
  private state: ClientState = { type: 'idle' };
  private breadcrumbs: BreadcrumbTrail;
  private networkInterceptor: NetworkInterceptor | null = null;
  private pollTimer: ReturnType<typeof setInterval> | null = null;
  private logBuffer: LogEntry[] = [];
  private traceBuffer: TraceSpan[] = [];
  private networkBuffer: NetworkRequest[] = [];
  private flushTimer: ReturnType<typeof setInterval> | null = null;
  private lastEtag: string | null = null;

  private constructor(config: DiagnosticsClientOptions) {
    this.config = {
      ...config,
      pollIntervalMs: config.pollIntervalMs ?? 5000,
      maxBreadcrumbs: config.maxBreadcrumbs ?? 100,
      captureConsole: config.captureConsole ?? true,
      captureErrors: config.captureErrors ?? true,
      captureNetwork: config.captureNetwork ?? true,
      networkExcludePatterns: config.networkExcludePatterns ?? [],
      logger: config.logger ?? {
        debug: () => {},
        info: () => {},
        warn: () => {},
        error: () => {},
      },
    };

    this.breadcrumbs = new BreadcrumbTrail({
      maxSize: this.config.maxBreadcrumbs,
    });
  }

  /**
   * Get singleton instance
   */
  static getInstance(config?: DiagnosticsClientOptions): DiagnosticsClient {
    if (!DiagnosticsClient.instance) {
      if (!config) {
        throw new Error('DiagnosticsClient must be initialized with config first');
      }
      DiagnosticsClient.instance = new DiagnosticsClient(config);
    }
    return DiagnosticsClient.instance;
  }

  /**
   * Check if client is initialized
   */
  static isInitialized(): boolean {
    return DiagnosticsClient.instance !== null;
  }

  /**
   * Reset singleton (for testing)
   */
  static reset(): void {
    DiagnosticsClient.instance?.stop();
    DiagnosticsClient.instance = null;
  }

  /**
   * Start polling for active debug sessions
   */
  async start(): Promise<void> {
    if (this.state.type === 'polling' || this.state.type === 'active') {
      this.config.logger.warn('[diagnostics] Already started');
      return;
    }

    this.state = { type: 'polling', session: null };
    this.config.logger.info('[diagnostics] Starting diagnostics client');

    // Initial poll
    await this.pollForSession();

    // Start polling timer
    this.pollTimer = setInterval(() => {
      this.pollForSession().catch(err => {
        this.config.logger.error('[diagnostics] Poll error', { error: err.message });
      });
    }, this.config.pollIntervalMs);

    // Start auto-flush timer (every 30 seconds)
    this.flushTimer = setInterval(() => {
      this.flush().catch(err => {
        this.config.logger.error('[diagnostics] Flush error', { error: err.message });
      });
    }, 30000);

    // Setup auto-capture if configured
    if (this.config.captureNetwork) {
      this.setupNetworkCapture();
    }

    if (this.config.captureConsole) {
      this.setupConsoleCapture();
    }

    if (this.config.captureErrors) {
      this.setupErrorCapture();
    }

    this.breadcrumbs.add('diagnostics', 'Client started');
  }

  /**
   * Stop polling and cleanup
   */
  stop(): void {
    this.config.logger.info('[diagnostics] Stopping diagnostics client');

    if (this.pollTimer) {
      clearInterval(this.pollTimer);
      this.pollTimer = null;
    }

    if (this.flushTimer) {
      clearInterval(this.flushTimer);
      this.flushTimer = null;
    }

    this.networkInterceptor?.stop();
    this.networkInterceptor = null;

    // Final flush
    this.flush().catch(() => {});

    this.state = { type: 'idle' };
    this.breadcrumbs.add('diagnostics', 'Client stopped');
  }

  /**
   * Check if a debug session is currently active
   */
  isSessionActive(): boolean {
    return this.state.type === 'active';
  }

  /**
   * Get current session if active
   */
  getCurrentSession(): DiagnosticsSession | null {
    return this.state.type === 'active' || this.state.type === 'polling'
      ? (this.state as { session: DiagnosticsSession | null }).session
      : null;
  }

  /**
   * Get current client state
   */
  getState(): ClientState {
    return this.state;
  }

  /**
   * Record a log entry
   */
  log(level: LogLevel, message: string, context: Record<string, unknown> = {}): void {
    const entry: LogEntry = {
      level,
      message,
      timestamp: new Date().toISOString(),
      module: (context.module as string) ?? 'unknown',
      context,
      correlationId: context.correlationId as string,
    };

    this.logBuffer.push(entry);
    this.breadcrumbs.add('log', `[${level.toUpperCase()}] ${message.slice(0, 100)}`, { level });

    // Auto-flush on fatal
    if (level === 'fatal') {
      this.flush().catch(() => {});
    }
  }

  /**
   * Record a trace span (auto-instrumented)
   */
  async trace<T>(name: string, operation: () => Promise<T>): Promise<T>;
  async trace<T>(name: string, operation: () => T): Promise<T>;
  async trace<T>(name: string, operation: () => T | Promise<T>): Promise<T> {
    const span: TraceSpan = {
      spanId: this.generateId(),
      name,
      kind: 'internal',
      startTime: new Date().toISOString(),
      attributes: {},
      status: 'unset',
    };

    this.breadcrumbs.add('trace', `Starting: ${name}`, { spanId: span.spanId });

    try {
      const result = await operation();
      span.endTime = new Date().toISOString();
      span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime();
      span.status = 'ok';
      this.traceBuffer.push(span);
      this.breadcrumbs.add('trace', `Completed: ${name}`, {
        spanId: span.spanId,
        durationMs: span.durationMs,
      });
      return result;
    } catch (error) {
      span.endTime = new Date().toISOString();
      span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime();
      span.status = 'error';
      span.statusMessage = error instanceof Error ? error.message : String(error);
      this.traceBuffer.push(span);
      this.breadcrumbs.add('trace', `Failed: ${name}`, {
        spanId: span.spanId,
        error: span.statusMessage,
      });
      throw error;
    }
  }

  /**
   * Add a manual breadcrumb
   */
  breadcrumb(category: string, message: string, data?: Record<string, unknown>): void {
    this.breadcrumbs.add(category, message, data);
  }

  /**
   * Get all breadcrumbs
   */
  getBreadcrumbs(): Breadcrumb[] {
    return this.breadcrumbs.getAll();
  }

  /**
   * Collect and return device state
   */
  collectDeviceState(): DeviceState {
    return collectDeviceState();
  }

  /**
   * Poll server for active session config
   */
  private async pollForSession(): Promise<void> {
    try {
      const url = new URL('/api/diagnostics/config', this.config.serverUrl);
      url.searchParams.set('productId', this.config.productId);
      url.searchParams.set('installId', this.config.anonymousInstallId);

      const headers: Record<string, string> = {
        Accept: 'application/json',
      };

      if (this.lastEtag) {
        headers['If-None-Match'] = this.lastEtag;
      }

      const token = await this.getAuthToken();
      if (token) {
        headers['Authorization'] = `Bearer ${token}`;
      }

      const response = await fetch(url.toString(), { headers });

      if (response.status === 304) {
        // No change
        return;
      }

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      // Store ETag for caching
      const etag = response.headers.get('ETag');
      if (etag) {
        this.lastEtag = etag;
      }

      const session: DiagnosticsSession | null = await response.json();

      // Update state
      if (session && session.status === 'active') {
        if (this.state.type !== 'active') {
          this.config.logger.info('[diagnostics] Session activated', { sessionId: session.id });
          this.breadcrumbs.add('diagnostics', 'Session activated', { sessionId: session.id });
        }
        this.state = { type: 'active', session };
      } else {
        if (this.state.type === 'active') {
          this.config.logger.info('[diagnostics] Session ended');
          this.breadcrumbs.add('diagnostics', 'Session ended');
        }
        this.state = { type: 'polling', session: null };
      }
    } catch (error) {
      this.config.logger.error('[diagnostics] Failed to poll for session', {
        error: error instanceof Error ? error.message : String(error),
      });
      this.state = {
        type: 'error',
        error: error instanceof Error ? error : new Error(String(error)),
      };
    }
  }

  /**
   * Flush buffered data to server
   */
  private async flush(): Promise<void> {
    const session = this.getCurrentSession();
    if (!session) {
      // No active session, clear buffers
      this.logBuffer = [];
      this.traceBuffer = [];
      this.networkBuffer = [];
      return;
    }

    const sessionId = session.id;

    const logs = this.logBuffer.splice(0, 50); // Server max: 50
    const traces = this.traceBuffer.splice(0, 50); // Server max: 50
    const network = this.networkBuffer.splice(0, 50);
    const crumbs = this.breadcrumbs.getAll();
    this.breadcrumbs.clear();

    // Encode breadcrumbs + network captures as log entries so we can ingest
    // without requiring additional server-side schemas/endpoints.
    const synthesizedLogs = [] as LogEntry[];

    for (const c of crumbs) {
      synthesizedLogs.push({
        level: 'info',
        message: `[breadcrumb] ${c.category}: ${c.message}`,
        timestamp: c.timestamp,
        module: 'diagnostics.breadcrumb',
        context: c.data ?? {},
      });
    }

    for (const n of network) {
      synthesizedLogs.push({
        level: n.error ? 'error' : 'info',
        message: `[network] ${n.method} ${n.url} ${n.status ?? ''}`.trim(),
        timestamp: n.startTime,
        module: 'diagnostics.network',
        context: {
          requestHeaders: n.requestHeaders,
          requestBody: n.requestBody,
          status: n.status,
          responseHeaders: n.responseHeaders,
          responseBody: n.responseBody,
          startTime: n.startTime,
          endTime: n.endTime,
          durationMs: n.durationMs,
          error: n.error,
        },
      });
    }

    const allLogs = [...logs, ...synthesizedLogs];

    if (allLogs.length === 0 && traces.length === 0) {
      return;
    }

    const token = await this.getAuthToken();
    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    };

    try {
      if (allLogs.length > 0) {
        const url = new URL(
          `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/logs`,
          this.config.serverUrl
        );
        const response = await fetch(url.toString(), {
          method: 'POST',
          headers,
          body: JSON.stringify({ sessionId, logs: allLogs }),
        });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
      }

      if (traces.length > 0) {
        const url = new URL(
          `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/traces`,
          this.config.serverUrl
        );
        const response = await fetch(url.toString(), {
          method: 'POST',
          headers,
          body: JSON.stringify({ sessionId, traces }),
        });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
      }

      this.config.logger.debug('[diagnostics] Flushed batch', {
        logs: allLogs.length,
        traces: traces.length,
      });
    } catch (error) {
      this.config.logger.error('[diagnostics] Failed to flush batch', {
        error: error instanceof Error ? error.message : String(error),
      });

      // Put items back in buffers for retry
      if (logs.length > 0) this.logBuffer.unshift(...logs);
      if (traces.length > 0) this.traceBuffer.unshift(...traces);
      if (network.length > 0) this.networkBuffer.unshift(...network);

      // Breadcrumbs were converted; keep a small breadcrumb trail hint for later flush.
      for (const c of crumbs.slice(-10)) {
        this.breadcrumbs.add(c.category, c.message, c.data);
      }
    }
  }

  /**
   * Setup network capture
   */
  private setupNetworkCapture(): void {
    this.networkInterceptor = new NetworkInterceptor(
      request => {
        this.networkBuffer.push(request);
      },
      {
        excludePatterns: this.config.networkExcludePatterns,
      }
    );
    this.networkInterceptor.start();
    this.breadcrumbs.add('diagnostics', 'Network capture enabled');
  }

  /**
   * Setup console capture
   */
  /* eslint-disable no-console -- This method intentionally wraps console APIs to capture diagnostics, then forwards to the originals. */
  private setupConsoleCapture(): void {
    const originalConsole = {
      log: console.log.bind(console),
      info: console.info.bind(console),
      warn: console.warn.bind(console),
      error: console.error.bind(console),
    };

    const capture = (level: LogLevel, args: unknown[]) => {
      const message = args
        .map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a)))
        .join(' ');
      this.log(level, message, { module: 'console', source: 'captured' });
    };

    console.log = (...args: unknown[]) => {
      capture('debug', args);
      originalConsole.log(...args);
    };
    console.info = (...args: unknown[]) => {
      capture('info', args);
      originalConsole.info(...args);
    };
    console.warn = (...args: unknown[]) => {
      capture('warn', args);
      originalConsole.warn(...args);
    };
    console.error = (...args: unknown[]) => {
      capture('error', args);
      originalConsole.error(...args);
    };

    this.breadcrumbs.add('diagnostics', 'Console capture enabled');
  }
  /* eslint-enable no-console */

  /**
   * Setup error capture
   */
  private setupErrorCapture(): void {
    if (typeof window === 'undefined') return;

    const handler = (event: ErrorEvent) => {
      this.log('error', event.message, {
        module: 'window.onerror',
        source: 'captured',
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
        error: event.error?.stack,
      });
      this.breadcrumbs.add('error', `Uncaught: ${event.message.slice(0, 100)}`);
    };

    window.addEventListener('error', handler);
    this.breadcrumbs.add('diagnostics', 'Error capture enabled');
  }

  /**
   * Get auth token
   */
  private async getAuthToken(): Promise<string | null> {
    if (!this.config.getAuthToken) return null;
    try {
      const token = await this.config.getAuthToken();
      return token;
    } catch {
      return null;
    }
  }

  /**
   * Generate unique ID
   */
  private generateId(): string {
    return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
  }
}
