/**
 * Cockroach Crawler Engine
 * Depth-first traversal with loop protection and destructive-action guards
 */

import { chromium, webkit, type Browser, type BrowserContext, type Page, type ElementHandle } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import {
  type Viewport,
  type BrowserProfile,
  type CockroachOptions,
  VIEWPORTS,
  BROWSER_PROFILES,
  PRIORITY_ROUTES,
  DESTRUCTIVE_SELECTORS,
  DESTRUCTIVE_TEXT_PATTERNS,
  SKIP_URL_PATTERNS,
  CLICK_DELAY_MS,
  PAGE_TIMEOUT_MS,
  MAX_CLICKS_PER_ELEMENT,
} from './config.js';
import { BugDetector, type Bug } from './bug-detector.js';

interface CrawlState {
  visitedUrls: Set<string>;
  clickedElements: Map<string, number>; // elementSig -> click count
  actionCount: number;
  depth: number;
  screenshots: string[];
}

interface CrawlResult {
  device: string;
  browser: string;
  bugs: Bug[];
  screenshots: string[];
  pagesVisited: number;
  actionsPerformed: number;
  durationMs: number;
  networkSummary: { total: number; errors: number; slow: number };
  consoleErrorCount: number;
}

export class Crawler {
  private options: CockroachOptions;

  constructor(options: CockroachOptions) {
    this.options = options;
  }

  private async navigateAndSettle(page: Page, url: string): Promise<void> {
    try {
      await page.goto(url, { waitUntil: 'domcontentloaded', timeout: PAGE_TIMEOUT_MS });
    } catch (err) {
      await page.goto(url, { waitUntil: 'commit', timeout: 5000 }).catch(() => {
        throw err;
      });
    }
    await this.settlePage(page);
  }

  private async settlePage(page: Page): Promise<void> {
    await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
    await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
    await page.waitForTimeout(CLICK_DELAY_MS);
  }

  /** Run the full crawl across all device/browser combos */
  async run(): Promise<CrawlResult[]> {
    const results: CrawlResult[] = [];

    // Select viewports and browsers
    const viewports = this.options.mobileOnly ? VIEWPORTS : VIEWPORTS;
    const browsers = BROWSER_PROFILES;

    // For efficiency, group by engine to reuse browser instances
    const engineGroups = new Map<string, BrowserProfile[]>();
    for (const bp of browsers) {
      const list = engineGroups.get(bp.engine) || [];
      list.push(bp);
      engineGroups.set(bp.engine, list);
    }

    const COMBO_TIMEOUT_MS = 180_000; // 3 minutes per combo max

    for (const [engine, profiles] of engineGroups) {
      console.log(`\n  Launching ${engine}...`);
      const browserType = engine === 'webkit' ? webkit : chromium;
      let browser: Browser;
      try {
        browser = await browserType.launch({ headless: this.options.headless });
      } catch (err: any) {
        console.error(`    Failed to launch ${engine}: ${err.message}`);
        continue;
      }

      for (const profile of profiles) {
        for (const viewport of viewports) {
          const combo = `${profile.name}_${viewport.name}`;
          console.log(`    Crawling: ${combo}`);

          try {
            const result = await Promise.race([
              this.crawlCombo(browser, profile, viewport, combo),
              new Promise<never>((_, reject) =>
                setTimeout(() => reject(new Error('Combo timeout')), COMBO_TIMEOUT_MS)
              ),
            ]);
            results.push(result);
            console.log(`      Done: ${result.pagesVisited} pages, ${result.actionsPerformed} actions, ${result.bugs.length} bugs`);
          } catch (err: any) {
            console.error(`      TIMEOUT/CRASH on ${combo}: ${err.message}`);
          }
        }
      }

      await browser.close();
    }

    return results;
  }

  private async crawlCombo(
    browser: Browser,
    profile: BrowserProfile,
    viewport: Viewport,
    comboName: string
  ): Promise<CrawlResult> {
    const startTime = Date.now();
    const detector = new BugDetector(viewport.name, profile.name);

    // Create context with mobile settings, cleared cookies
    const context = await browser.newContext({
      viewport: { width: viewport.width, height: viewport.height },
      deviceScaleFactor: viewport.deviceScaleFactor || 2,
      isMobile: viewport.isMobile,
      hasTouch: viewport.hasTouch,
      userAgent: profile.userAgent,
      // No storage state = logged out
    });

    // Enable tracing if requested
    if (this.options.tracing) {
      await context.tracing.start({ screenshots: true, snapshots: true });
    }

    const page = await context.newPage();
    page.setDefaultTimeout(PAGE_TIMEOUT_MS);
    detector.attach(page);

    const state: CrawlState = {
      visitedUrls: new Set(),
      clickedElements: new Map(),
      actionCount: 0,
      depth: 0,
      screenshots: [],
    };

    // Screenshot helper
    const screenshot = async (label: string) => {
      const dir = path.join(this.options.screenshotDir, comboName);
      fs.mkdirSync(dir, { recursive: true });
      const filename = `${String(state.actionCount).padStart(3, '0')}_${label.replace(/[^a-zA-Z0-9]/g, '_')}.png`;
      const filepath = path.join(dir, filename);
      try {
        await page.screenshot({ path: filepath, fullPage: false });
        state.screenshots.push(filepath);
      } catch {
        // page might have navigated
      }
    };

    // Phase 1: Visit priority routes
    for (const route of PRIORITY_ROUTES) {
      if (state.actionCount >= this.options.maxActions) break;
      const url = `${this.options.baseUrl}${route}`;

      try {
        detector.pushStep(`Navigate to ${route}`);
        await this.navigateAndSettle(page, url);
        state.visitedUrls.add(this.normalizeUrl(page.url()));
        await screenshot(`page_${route.replace(/\//g, '_') || 'home'}`);
        await detector.checkLayout(page);
        await detector.checkScrollLock(page);
        state.actionCount++;
      } catch (err: any) {
        detector.pushStep(`FAILED to load ${route}: ${err.message}`);
        await screenshot(`error_${route.replace(/\//g, '_')}`);
      }
    }

    // Phase 2: Interactive crawl starting from homepage
    if (this.options.maxDepth > 0 && state.actionCount < this.options.maxActions) {
      try {
        await this.navigateAndSettle(page, this.options.baseUrl);
        await this.interactiveCrawl(page, detector, state, comboName, 0);
      } catch (err: any) {
        console.error(`      Interactive crawl error: ${err.message}`);
      }
    }

    // Save trace
    if (this.options.tracing) {
      const traceDir = path.join(this.options.outputDir, 'traces');
      fs.mkdirSync(traceDir, { recursive: true });
      try {
        await context.tracing.stop({
          path: path.join(traceDir, `${comboName}.zip`),
        });
      } catch {
        // tracing may fail if context is closed
      }
    }

    await context.close();

    const netSummary = detector.getNetworkSummary();
    return {
      device: viewport.name,
      browser: profile.name,
      bugs: detector.getBugs(),
      screenshots: state.screenshots,
      pagesVisited: state.visitedUrls.size,
      actionsPerformed: state.actionCount,
      durationMs: Date.now() - startTime,
      networkSummary: { total: netSummary.total, errors: netSummary.errors, slow: netSummary.slow },
      consoleErrorCount: detector.getConsoleErrors().length,
    };
  }

  private async interactiveCrawl(
    page: Page,
    detector: BugDetector,
    state: CrawlState,
    comboName: string,
    depth: number
  ): Promise<void> {
    if (depth >= this.options.maxDepth || state.actionCount >= this.options.maxActions) return;

    const currentUrl = page.url();

    // Find all clickable elements
    const targets = await this.discoverTargets(page);
    console.log(`        Depth ${depth}: found ${targets.length} targets on ${new URL(currentUrl).pathname}`);

    for (const target of targets) {
      if (state.actionCount >= this.options.maxActions) break;

      // Skip if clicked too many times
      const clickCount = state.clickedElements.get(target.signature) || 0;
      if (clickCount >= MAX_CLICKS_PER_ELEMENT) continue;

      // Skip destructive actions
      if (this.isDestructive(target)) continue;

      // Skip external links
      if (target.href && SKIP_URL_PATTERNS.some((p) => p.test(target.href!))) continue;

      // Click
      try {
        detector.pushStep(`Click "${target.text}" (${target.tag}) on ${new URL(currentUrl).pathname}`);
        state.clickedElements.set(target.signature, clickCount + 1);
        state.actionCount++;

        const urlBefore = page.url();

        const clickResult = await target.element.click({ timeout: 3000 }).catch(() => null);
        if (clickResult === null) {
          throw new Error('click returned null');
        }

        await Promise.race([
          page.waitForURL((nextUrl) => nextUrl.toString() !== urlBefore, { timeout: 3000 }).catch(() => null),
          page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => null),
          page.waitForTimeout(CLICK_DELAY_MS),
        ]);
        await this.settlePage(page);

        const urlAfter = page.url();
        const navigated = urlAfter !== urlBefore;

        // Screenshot after interaction
        const dir = path.join(this.options.screenshotDir, comboName);
        fs.mkdirSync(dir, { recursive: true });
        const filename = `${String(state.actionCount).padStart(3, '0')}_click_${target.text.slice(0, 20).replace(/[^a-zA-Z0-9]/g, '_')}.png`;
        try {
          await page.screenshot({ path: path.join(dir, filename), fullPage: false });
          state.screenshots.push(path.join(dir, filename));
        } catch {
          // page may have navigated
        }

        // Layout check after interaction
        await detector.checkLayout(page);
        await detector.checkScrollLock(page);

        if (navigated) {
          const normalizedAfter = this.normalizeUrl(urlAfter);

          // Check for redirect loops
          if (state.visitedUrls.has(normalizedAfter)) {
            detector.pushStep(`Already visited ${normalizedAfter}, going back`);
          } else {
            state.visitedUrls.add(normalizedAfter);

            // Check for 404
            const is404 = await page.evaluate(() => {
              return document.title.includes('404') ||
                document.body.innerText.includes('Page not found') ||
                document.body.innerText.includes('This page could not be found');
            }).catch(() => false);

            if (is404) {
              detector.pushStep(`404 at ${urlAfter}`);
            }

            // Recurse into new page
            if (this.isSameSite(urlAfter) && depth < this.options.maxDepth - 1) {
              await this.interactiveCrawl(page, detector, state, comboName, depth + 1);
            }
          }

          // Navigate back to continue exploring
          try {
            await page.goBack({ waitUntil: 'domcontentloaded', timeout: PAGE_TIMEOUT_MS });
            await this.settlePage(page);
          } catch {
            // If back fails, go to the page we were on
            try {
              await this.navigateAndSettle(page, currentUrl);
            } catch {
              return; // Give up on this branch
            }
          }
        } else {
          // UI change without navigation — check for modals/popovers
          await this.dismissModals(page, detector, state, comboName);
        }
      } catch (err: any) {
        // Element might have become stale
        detector.pushStep(`Click failed on "${target.text}": ${err.message}`);
      }
    }
  }

  /** Find all clickable elements on the page */
  private async discoverTargets(page: Page): Promise<Array<{
    element: ElementHandle;
    tag: string;
    text: string;
    href?: string;
    signature: string;
  }>> {
    const targets: Array<{
      element: ElementHandle;
      tag: string;
      text: string;
      href?: string;
      signature: string;
    }> = [];

    try {
      const elements = await page.$$('a, button, [role="button"], [onclick], input[type="submit"], .cursor-pointer');

      for (const el of elements) {
        try {
          const info = await el.evaluate((node) => {
            const rect = node.getBoundingClientRect();
            const style = window.getComputedStyle(node);
            // Skip invisible elements
            if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return null;
            if (rect.width === 0 || rect.height === 0) return null;
            // Skip elements far off screen
            if (rect.top > window.innerHeight * 3) return null;

            return {
              tag: node.tagName.toLowerCase(),
              text: (node.textContent || '').trim().slice(0, 60),
              href: (node as HTMLAnchorElement).href || '',
              className: (node.className || '').toString().slice(0, 80),
              id: node.id || '',
              ariaLabel: node.getAttribute('aria-label') || '',
              rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
            };
          });

          if (!info) continue;

          const signature = `${info.tag}|${info.id || info.className.split(' ')[0]}|${info.text.slice(0, 30)}|${info.rect.x},${info.rect.y}`;

          targets.push({
            element: el,
            tag: info.tag,
            text: info.text || info.ariaLabel || info.id || 'unknown',
            href: info.href || undefined,
            signature,
          });
        } catch {
          // Element may have been removed
        }
      }
    } catch {
      // Page may have navigated
    }

    // Prioritize: nav items first, then buttons, then links
    targets.sort((a, b) => {
      const priority = (t: typeof a) => {
        if (t.text.toLowerCase().includes('menu') || t.text.toLowerCase().includes('nav')) return 0;
        if (t.tag === 'button') return 1;
        if (t.tag === 'a' && t.href) return 2;
        return 3;
      };
      return priority(a) - priority(b);
    });

    return targets;
  }

  /** Check if a target is destructive and should be skipped */
  private isDestructive(target: { text: string; tag: string; signature: string }): boolean {
    if (DESTRUCTIVE_TEXT_PATTERNS.some((p) => p.test(target.text))) return true;
    return false;
  }

  /** Dismiss any open modals/dialogs to continue traversal */
  private async dismissModals(
    page: Page,
    detector: BugDetector,
    state: CrawlState,
    comboName: string
  ): Promise<void> {
    try {
      // Look for close buttons in modals
      const closeButtons = await page.$$([
        '.modal-backdrop button[aria-label="Close"]',
        '[role="dialog"] button[aria-label="Close"]',
        '.fixed.inset-0 button[aria-label="Close"]',
        'button:has(svg) >> nth=0', // Common close button pattern
      ].join(', '));

      for (const btn of closeButtons.slice(0, 2)) {
        try {
          const isVisible = await btn.isVisible();
          if (isVisible) {
            detector.pushStep('Dismiss modal via close button');
            await btn.click({ timeout: 2000 });
            await page.waitForTimeout(300);
            break;
          }
        } catch {
          // ignore
        }
      }

      // Click backdrop to dismiss
      const backdrop = await page.$('.modal-backdrop, .fixed.inset-0');
      if (backdrop) {
        try {
          const isVisible = await backdrop.isVisible();
          if (isVisible) {
            // Click at position (5,5) which is the backdrop, not the modal content
            await backdrop.click({ position: { x: 5, y: 5 }, timeout: 2000 });
            await page.waitForTimeout(300);
          }
        } catch {
          // ignore
        }
      }

      // Press Escape as fallback
      try {
        await page.keyboard.press('Escape');
        await page.waitForTimeout(300);
      } catch {
        // ignore
      }
    } catch {
      // ignore
    }
  }

  private normalizeUrl(url: string): string {
    try {
      const u = new URL(url);
      return `${u.pathname}${u.search}`;
    } catch {
      return url;
    }
  }

  private isSameSite(url: string): boolean {
    try {
      const u = new URL(url);
      const base = new URL(this.options.baseUrl);
      return u.hostname === base.hostname || u.hostname.endsWith(`.${base.hostname}`);
    } catch {
      return false;
    }
  }
}
