import { Page } from "@playwright/test";
import path = require("path");
import fs = require("fs-extra");
import { PNG } from "pngjs";
import pixelmatchModule = require("pixelmatch");
const pixelmatch = (pixelmatchModule as any).default || pixelmatchModule;
import { Logger } from "winston";

export type VrtMode = "baseline" | "test" | "off";

export interface VrtCaptureOptions {
  /** Full-page screenshot vs viewport only */
  fullPage?: boolean;
  /** Additional CSS selectors to mask (black out) before capture */
  maskSelectors?: string[];
  /** Extra stabilisation delay in ms before capture */
  stabilisationDelay?: number;
  /** Custom viewport to set before capture */
  viewport?: { width: number; height: number };
}

/** A contiguous region of pixel changes detected in the diff */
export interface ChangeRegion {
  label: string;
  bounds: { x: number; y: number; width: number; height: number };
  changedPixels: number;
  totalPixels: number;
  changePercent: number;
  shareOfTotalDiff: number;
  location: string;
  severity: "minor" | "moderate" | "significant";
}

export interface VrtDiffResult {
  snapshotName: string;
  passed: boolean;
  mismatchPercentage: number;
  mismatchPixels: number;
  totalPixels: number;
  baselinePath: string;
  testPath: string;
  diffPath: string | null;
  heatmapPath: string | null;
  changeRegions: ChangeRegion[];
  /** Image dimensions for hover-highlight scaling */
  imageWidth: number;
  imageHeight: number;
}

interface VrtConfig {
  /** Per-pixel colour tolerance (0–1). Default: 0.1 */
  threshold: number;
  /** Max % of total pixels that may differ before the snapshot fails. Default: 0.1 */
  failureThreshold: number;
  /** Delay in ms before each capture for page stabilisation. Default: 500 */
  stabilisationDelay: number;
  /** Global CSS selectors to mask before every capture */
  globalMaskSelectors: string[];
  /** CSS selectors for popup/banner dismissal */
  dismissSelectors: string[];
}

// Defaults are intentionally empty. Extend per-call via VrtCaptureOptions, or
// project-wide via VRT_GLOBAL_MASK_SELECTORS / VRT_DISMISS_SELECTORS in `.env`
// (comma-separated CSS selectors).
const _envList = (name: string): string[] => {
  const raw = process.env[name];
  if (!raw) return [];
  return raw.split(",").map((s) => s.trim()).filter(Boolean);
};

const DEFAULT_VRT_CONFIG: VrtConfig = {
  threshold: 0.1,
  failureThreshold: 0.1,
  stabilisationDelay: 500,
  globalMaskSelectors: _envList("VRT_GLOBAL_MASK_SELECTORS"),
  dismissSelectors: _envList("VRT_DISMISS_SELECTORS"),
};

/**
 * VRTCapture — Inline visual regression testing utility for Cucumber + Playwright tests.
 *
 * Usage:
 *   - VRT_MODE=baseline : captures and saves screenshots as baselines
 *   - VRT_MODE=test     : captures, compares against baselines, generates diffs
 *   - VRT_MODE=off      : no-op (default)
 *
 * Baselines are stored per sprint/scenario/step-name for traceability.
 */
export class VRTCapture {
  private mode: VrtMode;
  private baselineDir: string;
  private testResultsDir: string;
  private diffDir: string;
  private sprint: string;
  private scenarioName: string;
  private logger: Logger;
  private config: VrtConfig;
  private captureIndex: number = 0;
  private results: VrtDiffResult[] = [];

  constructor(logger: Logger, scenarioName: string, sprint?: string) {
    this.mode = (process.env.VRT_MODE?.toLowerCase() as VrtMode) || "off";
    this.sprint = sprint || process.env.VRT_SPRINT || "default";
    this.scenarioName = this.sanitizeName(scenarioName);
    this.logger = logger;

    const projectRoot = process.cwd();
    this.baselineDir = path.join(
      projectRoot,
      "vrt-baselines",
      this.sprint,
      this.scenarioName,
    );
    this.testResultsDir = path.join(
      projectRoot,
      "test-results",
      "vrt",
      "captures",
      this.scenarioName,
    );
    this.diffDir = path.join(
      projectRoot,
      "test-results",
      "vrt",
      "diffs",
      this.scenarioName,
    );

    // Load config from env or use defaults
    this.config = {
      ...DEFAULT_VRT_CONFIG,
      threshold:
        parseFloat(process.env.VRT_THRESHOLD || "") ||
        DEFAULT_VRT_CONFIG.threshold,
      failureThreshold:
        parseFloat(process.env.VRT_FAILURE_THRESHOLD || "") ||
        DEFAULT_VRT_CONFIG.failureThreshold,
      stabilisationDelay:
        parseInt(process.env.VRT_STABILISATION_DELAY || "", 10) ||
        DEFAULT_VRT_CONFIG.stabilisationDelay,
    };
  }

  /** Whether VRT is active for this run */
  get isActive(): boolean {
    return this.mode !== "off";
  }

  /** All diff results collected during this scenario */
  get diffResults(): VrtDiffResult[] {
    return [...this.results];
  }

  /**
   * Capture a visual snapshot at the current page state.
   * In baseline mode: saves the screenshot as the baseline reference.
   * In test mode: captures, compares against the baseline, and stores the diff.
   *
   * @param page - Playwright Page instance
   * @param snapshotName - Human-readable name for this capture point (e.g. "after_login", "dashboard_loaded")
   * @param options - Optional capture configuration
   * @returns VrtDiffResult in test mode, undefined in baseline mode
   */
  async capture(
    page: Page,
    snapshotName: string,
    options?: VrtCaptureOptions,
  ): Promise<VrtDiffResult | undefined> {
    if (this.mode === "off") return undefined;

    this.captureIndex++;
    const safeName = `${String(this.captureIndex).padStart(3, "0")}_${this.sanitizeName(snapshotName)}`;

    this.logger.info(
      `[VRT] Capturing snapshot: ${safeName} (mode=${this.mode})`,
    );

    try {
      // Apply stabilisation delay
      const delay =
        options?.stabilisationDelay ?? this.config.stabilisationDelay;
      if (delay > 0) {
        await page.waitForTimeout(delay);
      }

      // Dismiss popups/banners
      await this.dismissPopups(page);

      // Apply masks (black out dynamic content)
      const maskSelectors = [
        ...this.config.globalMaskSelectors,
        ...(options?.maskSelectors || []),
      ];
      await this.applyMasks(page, maskSelectors);

      // Set viewport if specified
      if (options?.viewport) {
        await page.setViewportSize(options.viewport);
      }

      // Take the screenshot
      const screenshotBuffer = await page.screenshot({
        fullPage: options?.fullPage ?? false,
        type: "png",
      });

      if (this.mode === "baseline") {
        return await this.saveBaseline(safeName, screenshotBuffer);
      } else {
        return await this.compareWithBaseline(safeName, screenshotBuffer);
      }
    } catch (error: any) {
      this.logger.error(
        `[VRT] Capture failed for ${safeName}: ${error.message}`,
      );
      // Don't fail the test for VRT errors — log and continue
      return undefined;
    }
  }

  /**
   * Save a screenshot as the new baseline.
   */
  private async saveBaseline(
    safeName: string,
    screenshotBuffer: Buffer,
  ): Promise<undefined> {
    fs.ensureDirSync(this.baselineDir);
    const baselinePath = path.join(this.baselineDir, `${safeName}.png`);
    fs.writeFileSync(baselinePath, screenshotBuffer);
    this.logger.info(`[VRT] Baseline saved: ${baselinePath}`);
    return undefined;
  }

  /**
   * Compare a test screenshot against the stored baseline.
   */
  private async compareWithBaseline(
    safeName: string,
    screenshotBuffer: Buffer,
  ): Promise<VrtDiffResult> {
    fs.ensureDirSync(this.testResultsDir);
    fs.ensureDirSync(this.diffDir);

    const testPath = path.join(this.testResultsDir, `${safeName}.png`);
    fs.writeFileSync(testPath, screenshotBuffer);

    const baselinePath = path.join(this.baselineDir, `${safeName}.png`);

    // No baseline exists — mark as new
    if (!fs.existsSync(baselinePath)) {
      const result: VrtDiffResult = {
        snapshotName: safeName,
        passed: true,
        mismatchPercentage: -1,
        mismatchPixels: 0,
        totalPixels: 0,
        baselinePath,
        testPath,
        diffPath: null,
        heatmapPath: null,
        changeRegions: [],
        imageWidth: 0,
        imageHeight: 0,
      };
      this.results.push(result);
      this.logger.warn(
        `[VRT] No baseline found for ${safeName} — marked as new`,
      );
      return result;
    }

    // Read and compare PNGs
    const baselinePng = PNG.sync.read(fs.readFileSync(baselinePath));
    const testPng = PNG.sync.read(screenshotBuffer);

    // Handle size mismatch — resize test to match baseline dimensions
    const width = Math.max(baselinePng.width, testPng.width);
    const height = Math.max(baselinePng.height, testPng.height);

    const normalizedBaseline = this.normalizeImage(baselinePng, width, height);
    const normalizedTest = this.normalizeImage(testPng, width, height);

    const diffPng = new PNG({ width, height });
    const mismatchPixels = pixelmatch(
      normalizedBaseline,
      normalizedTest,
      diffPng.data,
      width,
      height,
      {
        threshold: this.config.threshold,
        includeAA: false,
        alpha: 0.5,
        diffColor: [255, 0, 0],
        aaColor: [0, 0, 255],
      },
    );

    const totalPixels = width * height;
    const mismatchPercentage = (mismatchPixels / totalPixels) * 100;
    const passed = mismatchPercentage <= this.config.failureThreshold;

    let diffPath: string | null = null;
    let heatmapPath: string | null = null;
    let changeRegions: ChangeRegion[] = [];

    if (!passed) {
      diffPath = path.join(this.diffDir, `${safeName}-diff.png`);
      fs.writeFileSync(diffPath, PNG.sync.write(diffPng));

      // Generate enhanced heatmap
      heatmapPath = this.generateHeatmap(diffPng, safeName);

      // Analyse change regions
      changeRegions = this.analyseChangeRegions(
        diffPng,
        mismatchPixels,
        width,
        height,
      );

      this.logger.warn(
        `[VRT] DIFF DETECTED: ${safeName} — ${mismatchPercentage.toFixed(3)}% mismatch (${mismatchPixels}/${totalPixels} pixels), ${changeRegions.length} region(s)`,
      );
    } else {
      this.logger.info(
        `[VRT] PASSED: ${safeName} — ${mismatchPercentage.toFixed(3)}% mismatch`,
      );
    }

    const result: VrtDiffResult = {
      snapshotName: safeName,
      passed,
      mismatchPercentage,
      mismatchPixels,
      totalPixels,
      baselinePath,
      testPath,
      diffPath,
      heatmapPath,
      changeRegions,
      imageWidth: width,
      imageHeight: height,
    };
    this.results.push(result);
    return result;
  }

  /**
   * Generate an enhanced heatmap image from the raw diff.
   */
  private generateHeatmap(diffPng: PNG, safeName: string): string {
    const heatmap = new PNG({ width: diffPng.width, height: diffPng.height });
    Buffer.from(diffPng.data).copy(heatmap.data);

    const { data } = heatmap;
    for (let i = 0; i < data.length; i += 4) {
      const r = data[i],
        g = data[i + 1],
        b = data[i + 2];
      if (r > 200 && g < 50 && b < 50) {
        data[i] = 255;
        data[i + 1] = 0;
        data[i + 2] = 0;
        data[i + 3] = 200;
      } else if (r < 50 && g < 100 && b > 200) {
        data[i] = 0;
        data[i + 1] = 100;
        data[i + 2] = 255;
        data[i + 3] = 150;
      } else {
        const grey = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
        data[i] = grey;
        data[i + 1] = grey;
        data[i + 2] = grey;
        data[i + 3] = 60;
      }
    }

    const heatmapPath = path.join(this.diffDir, `${safeName}-heatmap.png`);
    fs.writeFileSync(heatmapPath, PNG.sync.write(heatmap));
    return heatmapPath;
  }

  /**
   * Analyse the diff image to identify contiguous regions of change.
   */
  private analyseChangeRegions(
    diffPng: PNG,
    totalMismatchPixels: number,
    imgWidth: number,
    imgHeight: number,
  ): ChangeRegion[] {
    if (totalMismatchPixels === 0) return [];

    const { width, height, data } = diffPng;
    const cellSize = 40;
    const cols = Math.ceil(width / cellSize);
    const rows = Math.ceil(height / cellSize);

    const grid: number[][] = Array.from({ length: rows }, () =>
      new Array(cols).fill(0),
    );
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const idx = (y * width + x) * 4;
        const r = data[idx],
          g = data[idx + 1],
          b = data[idx + 2];
        if ((r > 200 && g < 50 && b < 50) || (r < 50 && g < 100 && b > 200)) {
          grid[Math.floor(y / cellSize)][Math.floor(x / cellSize)]++;
        }
      }
    }

    const visited: boolean[][] = Array.from({ length: rows }, () =>
      new Array(cols).fill(false),
    );
    const regions: { cells: [number, number][]; changedPixels: number }[] = [];

    const floodFill = (
      startRow: number,
      startCol: number,
    ): { cells: [number, number][]; changedPixels: number } => {
      const stack: [number, number][] = [[startRow, startCol]];
      const cells: [number, number][] = [];
      let changedPixels = 0;
      while (stack.length > 0) {
        const [r, c] = stack.pop()!;
        if (r < 0 || r >= rows || c < 0 || c >= cols) continue;
        if (visited[r][c] || grid[r][c] === 0) continue;
        visited[r][c] = true;
        cells.push([r, c]);
        changedPixels += grid[r][c];
        stack.push([r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]);
      }
      return { cells, changedPixels };
    };

    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        if (!visited[r][c] && grid[r][c] > 0) {
          regions.push(floodFill(r, c));
        }
      }
    }

    regions.sort((a, b) => b.changedPixels - a.changedPixels);
    const topRegions = regions.slice(0, 10);

    return topRegions.map((region, idx) => {
      const minRow = Math.min(...region.cells.map(([r]) => r));
      const maxRow = Math.max(...region.cells.map(([r]) => r));
      const minCol = Math.min(...region.cells.map(([, c]) => c));
      const maxCol = Math.max(...region.cells.map(([, c]) => c));

      const x = minCol * cellSize;
      const y = minRow * cellSize;
      const w = (maxCol - minCol + 1) * cellSize;
      const h = (maxRow - minRow + 1) * cellSize;
      const totalPixelsInRegion = w * h;
      const changePercent =
        Math.round((region.changedPixels / totalPixelsInRegion) * 1000) / 10;
      const shareOfTotalDiff =
        Math.round((region.changedPixels / totalMismatchPixels) * 1000) / 10;

      const centerY = y + h / 2;
      const centerX = x + w / 2;
      const vPos =
        centerY < imgHeight * 0.33
          ? "top"
          : centerY < imgHeight * 0.66
            ? "middle"
            : "bottom";
      const hPos =
        centerX < imgWidth * 0.33
          ? "left"
          : centerX < imgWidth * 0.66
            ? "center"
            : "right";

      const severity: ChangeRegion["severity"] =
        changePercent > 50
          ? "significant"
          : changePercent > 15
            ? "moderate"
            : "minor";

      return {
        label: `Region ${idx + 1}`,
        bounds: { x, y, width: w, height: h },
        changedPixels: region.changedPixels,
        totalPixels: totalPixelsInRegion,
        changePercent,
        shareOfTotalDiff,
        location: `${vPos}-${hPos}`,
        severity,
      };
    });
  }

  /**
   * Normalize image data to the given dimensions (pad with transparent pixels if smaller).
   */
  private normalizeImage(png: PNG, width: number, height: number): Buffer {
    if (png.width === width && png.height === height) {
      return png.data as Buffer;
    }
    const normalized = new PNG({ width, height, fill: true });
    PNG.bitblt(png, normalized, 0, 0, png.width, png.height, 0, 0);
    return normalized.data as Buffer;
  }

  /**
   * Dismiss known popup/banner overlays before capture.
   */
  private async dismissPopups(page: Page): Promise<void> {
    for (const selector of this.config.dismissSelectors) {
      try {
        const el = page.locator(selector);
        if (await el.isVisible({ timeout: 500 }).catch(() => false)) {
          await page.evaluate((sel) => {
            document.querySelectorAll(sel).forEach((el) => {
              (el as HTMLElement).style.display = "none";
            });
          }, selector);
        }
      } catch {
        // Not critical — element may not exist
      }
    }
  }

  /**
   * Black out dynamic elements to stabilize screenshots.
   */
  private async applyMasks(page: Page, selectors: string[]): Promise<void> {
    for (const selector of selectors) {
      try {
        await page.evaluate((sel) => {
          document.querySelectorAll(sel).forEach((el) => {
            const htmlEl = el as HTMLElement;
            htmlEl.style.backgroundColor = "#000";
            htmlEl.style.color = "#000";
            htmlEl.style.backgroundImage = "none";
            htmlEl.style.boxShadow = "none";
          });
        }, selector);
      } catch {
        // Not critical — element may not exist
      }
    }
  }

  /**
   * Generate a summary of all VRT results for this scenario.
   */
  getSummary(): {
    total: number;
    passed: number;
    failed: number;
    newSnapshots: number;
  } {
    return {
      total: this.results.length,
      passed: this.results.filter((r) => r.passed && r.mismatchPercentage >= 0)
        .length,
      failed: this.results.filter((r) => !r.passed).length,
      newSnapshots: this.results.filter((r) => r.mismatchPercentage < 0).length,
    };
  }

  private sanitizeName(name: string): string {
    return name
      .replace(/[^a-zA-Z0-9_-]/g, "_")
      .replace(/_+/g, "_")
      .replace(/^_|_$/g, "")
      .toLowerCase()
      .substring(0, 100);
  }
}
