/**
 * Screenshot test fixture — works in two modes selected by env var:
 *
 *   SCREENSHOT_MODE=web       (default) — Playwright Chromium context
 *                               Outputs to dist/screenshots/_master/
 *   SCREENSHOT_MODE=electron  — Real Electron build via Playwright `_electron`
 *                               Outputs to dist/screenshots/_master_electron/
 *                               Captures macOS via renderer + deterministic
 *                               traffic-light overlay, Linux via OS-level
 *                               region tool (grim / import) for GTK chrome.
 *
 * Same `seededPage` / `screenshotMaster` API in both modes, so scenarios
 * import from a single fixture file regardless of mode.
 */

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
  _electron,
  test as base,
  type ElectronApplication,
  type Page,
} from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { ImportPage } from '../pages/import.page';
import { waitForAppReady } from '../utils/waits';
import {
  MASTER_DIR_ELECTRON,
  MASTER_DIR_WEB,
  MOBILE_VIEWPORTS,
  SCREENSHOT_BASE_DATE,
  type Locale,
  type Theme,
  type ViewportName,
} from './matrix';
import { compositeMacTrafficLights } from './composite-mac-chrome';
import { writeSeedFile } from './seed/build-seed';

const run = promisify(execFile);
const MODE: 'web' | 'electron' =
  process.env.SCREENSHOT_MODE === 'electron' ? 'electron' : 'web';

const SEED_DIR = path.resolve(__dirname, '..', '..', '.tmp', 'screenshot-seeds');
const MASTER_DIR = MODE === 'electron' ? MASTER_DIR_ELECTRON : MASTER_DIR_WEB;
const ELECTRON_MAIN = path.resolve(__dirname, '..', '..', 'electron', 'main.js');
const ELECTRON_USER_DATA = path.resolve(
  __dirname,
  '..',
  '..',
  '.tmp',
  'electron-user-data',
);
/**
 * Sandboxes / restricted shells often deny writes to `~/.config/` (Qt/KDE
 * libs, electron-log, Chromium SingletonLock all hit it). Redirect every
 * tool's config directory into a project-local path so they're happy.
 */
const SANDBOX_HOME_OVERRIDES = (() => {
  const cfg = path.resolve(__dirname, '..', '..', '.tmp', 'electron-xdg');
  fs.mkdirSync(cfg, { recursive: true });
  return {
    XDG_CONFIG_HOME: cfg,
    XDG_CACHE_HOME: cfg,
    XDG_DATA_HOME: cfg,
  } satisfies NodeJS.ProcessEnv;
})();

type ScreenshotFixtures = {
  locale: Locale;
  theme: Theme;
  customTheme: string | undefined;
  seedFile: string;
  electronApp: ElectronApplication | null;
  seededPage: Page;
  screenshotMaster: (scenario: string, name: string) => Promise<void>;
};

/**
 * Module-level latch so the noisy permission warning fires once per run, not
 * once per scene. Reset via `resetChromeFallbackWarning()` if useful in tests.
 */
let osChromeCaptureWarned = false;

/**
 * Capture the focused Electron window.
 *
 * macOS: use renderer capture and add hiddenInset traffic lights
 * deterministically. Playwright's `_electron.launch` does not always get the
 * same AppKit treatment as a LaunchServices-started `.app`, and OS-level
 * capture can miss the button overlay even when the content is correct.
 *
 * Linux: capture the full screen-space rect, including native GTK decoration.
 *
 * On Linux, `BrowserWindow.getBounds()` returns the outer frame in pixels, and
 * the matching OS tool produces 1:1 output.
 *
 * If the Linux OS-level capture fails, we fall back to `page.screenshot()` so
 * the rest of the single-session run still produces output. The fallback PNG
 * won't include native GTK chrome, but every other scene downstream of the
 * failure is salvaged.
 */
const captureWindowWithChrome = async (
  electronApp: ElectronApplication,
  page: Page,
  outPath: string,
): Promise<void> => {
  if (process.platform === 'darwin') {
    await page.screenshot({
      path: outPath,
      type: 'png',
      fullPage: false,
      animations: 'disabled',
      caret: 'hide',
    });
    await compositeMacTrafficLights(outPath);
    return;
  }

  try {
    // Beat for focus + paint to settle before the OS-level capture fires.
    const settleAndCapture = async (bin: string, args: string[]): Promise<void> => {
      await new Promise((r) => setTimeout(r, 300));
      console.log(`[screenshot] ${path.basename(outPath)} → ${bin} ${args.join(' ')}`);
      try {
        // execFile bypasses the shell — no quoting concerns, no metachar escapes.
        await run(bin, args, { env: { ...process.env, ...SANDBOX_HOME_OVERRIDES } });
      } catch (err) {
        // execFile throws { message, code, stdout, stderr }; surface stderr
        // because the default Error message just says "Command failed: …" and
        // hides the actual cause from the OS capture tool.
        const e = err as { stderr?: string; stdout?: string; message?: string };
        const stderr = (e.stderr ?? '').toString().trim();
        const stdout = (e.stdout ?? '').toString().trim();
        const tail = [stderr && `stderr: ${stderr}`, stdout && `stdout: ${stdout}`]
          .filter(Boolean)
          .join('; ');
        throw new Error(
          tail
            ? `${e.message ?? 'capture failed'} (${tail})`
            : (e.message ?? String(err)),
        );
      }
    };

    // ─── Linux: capture by screen rect (no per-window equivalent
    //      that's guaranteed to be on PATH). Output dims will match the
    //      window outer bounds. ─────────────────────────────────────────
    const b = await electronApp.evaluate(({ BrowserWindow }) => {
      const w = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
      if (!w) throw new Error('No Electron window to capture');
      w.show();
      w.focus();
      return w.getBounds();
    });

    for (const v of [b.x, b.y, b.width, b.height]) {
      if (typeof v !== 'number' || !Number.isFinite(v)) {
        throw new Error(`Non-finite window bound: ${JSON.stringify(b)}`);
      }
    }

    const isWayland =
      process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY;
    const [bin, args] = isWayland
      ? ['grim', ['-g', `${b.x},${b.y} ${b.width}x${b.height}`, outPath]]
      : [
          'import',
          ['-window', 'root', '-crop', `${b.width}x${b.height}+${b.x}+${b.y}`, outPath],
        ];
    await settleAndCapture(bin, args);
  } catch (err) {
    const msg = (err as Error).message ?? String(err);
    if (!osChromeCaptureWarned) {
      osChromeCaptureWarned = true;
      const hint =
        process.platform === 'linux'
          ? '\n  ↳ Linux: ensure `grim` (Wayland) or ImageMagick `import` (X11) ' +
            'is installed and on PATH.'
          : '';
      console.warn(
        '\n' +
          '════════════════════════════════════════════════════════════════════\n' +
          '⚠  OS-LEVEL SCREEN CAPTURE FAILED — RENDERER FALLBACK ACTIVE\n' +
          '════════════════════════════════════════════════════════════════════\n' +
          `  ${msg.split('\n')[0]}\n` +
          '  Captures will NOT include native GTK window chrome. The Flathub\n' +
          '  deliverables from this run are therefore NOT submission-ready.' +
          hint +
          '\n' +
          '════════════════════════════════════════════════════════════════════\n',
      );
      // Marker so the globalTeardown can re-surface this at the end of the
      // run — the warning above scrolls off in long runs and the user
      // notices the missing chrome only when reviewing the PNGs.
      try {
        fs.mkdirSync(MASTER_DIR, { recursive: true });
        fs.writeFileSync(
          path.join(MASTER_DIR, '.os-capture-failed'),
          `${new Date().toISOString()}\n${msg}\n`,
        );
      } catch {
        /* marker is best-effort */
      }
    }
    await page.screenshot({
      path: outPath,
      type: 'png',
      fullPage: false,
      animations: 'disabled',
      caret: 'hide',
    });
  }
};

const ONBOARDING_INIT = (): void => {
  localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
  localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
  localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
  localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
};

export const test = base.extend<ScreenshotFixtures>({
  locale: ['en', { option: true }] as never,
  theme: ['light', { option: true }] as never,
  customTheme: [
    process.env.SCREENSHOT_CUSTOM_THEME?.trim() || undefined,
    { option: true },
  ] as never,

  seedFile: async ({ locale, customTheme }, use) => {
    const file = writeSeedFile(SCREENSHOT_BASE_DATE, SEED_DIR, {
      locale,
      customTheme,
    });
    await use(file);
  },

  // Electron app is launched only in electron mode; web mode resolves to null.
  electronApp: async ({}, use, testInfo) => {
    if (MODE !== 'electron') {
      await use(null);
      return;
    }
    if (!fs.existsSync(ELECTRON_MAIN)) {
      throw new Error(`${ELECTRON_MAIN} missing. Run \`npm run electron:build\` first.`);
    }
    // Per-test user-data dir avoids the singleton-lock collision and lets us
    // run inside sandboxes where ~/.config/Electron is not writable.
    const userDataDir = path.join(
      ELECTRON_USER_DATA,
      `worker-${testInfo.workerIndex}-${Date.now()}`,
    );
    fs.mkdirSync(userDataDir, { recursive: true });

    // NODE_ENV is intentionally NOT set to 'DEV': start-app.js binds
    // `isShowDevTools = IS_DEV`, and electron/debug.ts auto-opens DevTools on
    // every `dom-ready` (so reloads between scenarios re-open them too).
    // Loading the dev server is governed by --custom-url, not IS_DEV, so we
    // can safely run with IS_DEV=false and keep the same URL.
    // `--no-sandbox` and `--disable-dev-shm-usage` are Linux/CI helper
    // flags. On macOS they're not needed, and `--no-sandbox` in particular
    // appears to suppress the hiddenInset traffic-lights when Electron is
    // launched as a child process (the same binary launched via `npm start`
    // shows them fine). Only pass them on Linux.
    const isMac = process.platform === 'darwin';
    const deviceScaleFactor =
      (testInfo.project.use as { deviceScaleFactor?: number }).deviceScaleFactor ?? 1;
    const electronApp = await _electron.launch({
      args: [
        ELECTRON_MAIN,
        `--custom-url=http://localhost:4242/`,
        `--user-data-dir=${userDataDir}`,
        ...(isMac ? [`--force-device-scale-factor=${deviceScaleFactor}`] : []),
        ...(isMac ? [] : ['--disable-dev-shm-usage', '--no-sandbox']),
      ],
      env: {
        ...process.env,
        NODE_ENV: 'PROD',
        // Tells main-window.ts to set `enableLargerThanScreen: true` so the
        // configured 1280×800 outer bounds aren't silently clamped to fit
        // available screen area below menu bar + dock. Without this, on
        // laptop displays setBounds(800) ends up as 780 and the captured
        // PNG fails Mac App Store dimension validation.
        SP_SCREENSHOT_MODE: '1',
        ...SANDBOX_HOME_OVERRIDES,
      },
      timeout: 60_000,
    });

    try {
      await use(electronApp);
    } finally {
      // electronApp.close() can hang on apps with pending async work. Race it
      // with a force-kill, but cancel the timer if close() finishes first so
      // we don't SIGKILL a recycled PID after the process is already gone.
      const proc = electronApp.process();
      let timer: NodeJS.Timeout | undefined;
      const killAfter = new Promise<void>((resolve) => {
        timer = setTimeout(() => {
          try {
            proc?.kill('SIGKILL');
          } catch {
            /* ignore */
          }
          resolve();
        }, 8_000);
      });
      await Promise.race([electronApp.close().catch(() => undefined), killAfter]);
      if (timer) clearTimeout(timer);
    }
  },

  page: async (
    { browser, baseURL, theme, locale, customTheme, electronApp },
    use,
    testInfo,
  ) => {
    // ─── electron mode ────────────────────────────────────────────────
    if (MODE === 'electron' && electronApp) {
      const page = await electronApp.firstWindow();
      await page.waitForLoadState('domcontentloaded');

      // Pin time so day-relative seed data ("today") renders against the
      // SCREENSHOT_BASE_DATE — same behavior as the web branch below.
      await page.clock.install({ time: SCREENSHOT_BASE_DATE }).catch(() => undefined);

      // Force window size + close auto-opened DevTools now that the window
      // exists. The app's `electron-window-state` defaults to 800×800.
      // Wrapped defensively — closeDevTools throws if DevTools isn't open,
      // and any throw inside an evaluate has been observed to take down the
      // renderer.
      const targetSize = (testInfo.project.use.viewport ?? {
        width: 1440,
        height: 900,
      }) as { width: number; height: number };
      const achieved = await electronApp
        .evaluate(
          ({ BrowserWindow }, size) => {
            try {
              const w =
                BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
              if (!w) return null;
              w.setBounds({ x: 0, y: 0, width: size.w, height: size.h });
              if (w.webContents.isDevToolsOpened()) {
                w.webContents.closeDevTools();
              }
              // Report what setBounds actually achieved vs. requested. On
              // macOS the OS may push y below the menu bar / shrink to fit
              // the screen; the result tells us whether 1280×800 stuck.
              return {
                bounds: w.getBounds(),
                contentBounds: w.getContentBounds(),
              };
            } catch {
              /* swallow — bounds/devtools are nice-to-have, not critical */
              return null;
            }
          },
          { w: targetSize.width, h: targetSize.height },
        )
        .catch(() => null);
      if (achieved) {
        const b = achieved.bounds;
        const c = achieved.contentBounds;
        console.log(
          `[screenshot] setBounds requested=${targetSize.width}x${targetSize.height} → ` +
            `outer=${b.x},${b.y} ${b.width}x${b.height}; ` +
            `content=${c.x},${c.y} ${c.width}x${c.height}`,
        );
      }
      await page.waitForTimeout(400);

      // Electron pages have no Playwright-level baseURL, so `page.goto('/#/x')`
      // fails with "invalid URL". Wrap goto to prepend the dev server origin
      // when given a relative path — keeps shared helpers (ImportPage,
      // gotoAndSettle) working unchanged in both modes.
      const origGoto = page.goto.bind(page);
      const electronBaseURL = 'http://localhost:4242';
      (page as unknown as { goto: typeof origGoto }).goto = ((
        url: string,
        opts?: Parameters<typeof origGoto>[1],
      ) =>
        origGoto(
          url.startsWith('http') ? url : electronBaseURL + url,
          opts,
        )) as typeof origGoto;
      await page.evaluate(
        ({ darkMode, customThemeId }) => {
          try {
            localStorage.setItem('DARK_MODE', darkMode);
            localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
            localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
            localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
            localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
            if (customThemeId) {
              localStorage.setItem('CUSTOM_THEME', `builtin:${customThemeId}`);
            }
          } catch {
            /* localStorage may be unavailable until renderer ready */
          }
        },
        { darkMode: theme, customThemeId: customTheme },
      );
      await page.evaluate(() => location.reload());
      await page.waitForLoadState('domcontentloaded');
      await waitForAppReady(page);
      // Suppress Material tooltips for the rest of the session (mirrors the
      // web-mode addInitScript). Electron has no addInitScript hook on the
      // first window, so inject after each navigation via page.on('load').
      const injectTooltipSuppress = async (): Promise<void> => {
        await page
          .evaluate(() => {
            const id = '__sp-screenshot-tooltip-suppress';
            if (document.getElementById(id)) return;
            const style = document.createElement('style');
            style.id = id;
            style.textContent =
              'mat-tooltip-component,.mat-mdc-tooltip,.cdk-overlay-container .mat-mdc-tooltip,.cdk-overlay-container .mat-tooltip,.cdk-overlay-container [role="tooltip"]{visibility:hidden!important;opacity:0!important}';
            document.head.appendChild(style);
          })
          .catch(() => undefined);
      };
      await injectTooltipSuppress();
      page.on('load', () => void injectTooltipSuppress());
      // Stamp the initial locale on window so screenshotMaster can route
      // captures into the correct `<locale>/` subdir even when applyLocale
      // hasn't run yet. Re-applied on every load so reloads don't lose it.
      const stampLocale = async (): Promise<void> => {
        await page
          .evaluate((l) => {
            (window as unknown as { __spCurrentLocale?: string }).__spCurrentLocale = l;
          }, locale)
          .catch(() => undefined);
      };
      await stampLocale();
      page.on('load', () => void stampLocale());
      page.on('pageerror', (err) => {
        console.error('[electron pageerror]', err.message);
      });
      await use(page);
      return;
    }

    // ─── web mode (default) ───────────────────────────────────────────
    const isMobileProject = (MOBILE_VIEWPORTS as readonly string[]).includes(
      testInfo.project.name,
    );
    const context = await browser.newContext({
      baseURL: baseURL ?? 'http://localhost:4242',
      userAgent: `PLAYWRIGHT PLAYWRIGHT-WORKER-${testInfo.workerIndex}`,
      storageState: undefined,
      // Pin navigator.language to en-US so ImportPage's English text matchers
      // ("Import/Export") work regardless of host locale. The actual UI locale
      // gets switched after seed import via globalConfig.localization.lng.
      locale: 'en-US',
    });
    const page = await context.newPage();

    await page.clock.install({ time: SCREENSHOT_BASE_DATE });

    await page.addInitScript(ONBOARDING_INIT);
    if (isMobileProject) {
      await page.addInitScript(() => {
        localStorage.setItem('SUP_NAV_SIDEBAR_EXPANDED', 'false');
      });
    }
    await page.addInitScript((darkMode) => {
      try {
        localStorage.setItem('DARK_MODE', darkMode);
      } catch {
        /* noop */
      }
    }, theme);
    // Mirror the DARK_MODE init script for the custom-theme picker. The
    // CustomThemeService reads LS.CUSTOM_THEME at construction; for E2E
    // screenshots we point it at a built-in theme by id (e.g. 'dracula').
    if (customTheme) {
      await page.addInitScript((id) => {
        try {
          localStorage.setItem('CUSTOM_THEME', `builtin:${id}`);
        } catch {
          /* noop */
        }
      }, customTheme);
    }
    // Stamp the initial locale on `window.__spCurrentLocale` so
    // screenshotMaster can route captures into the right `<locale>/` dir.
    // Updated by `helpers.applyLocale()` when scenes flip languages.
    await page.addInitScript((initialLocale) => {
      (window as unknown as { __spCurrentLocale?: string }).__spCurrentLocale =
        initialLocale;
    }, locale);
    // Suppress Material tooltips and CDK overlay tooltips for the duration of
    // capture. Cursor lingering at the last click position would otherwise pop
    // a tooltip into the screenshot. Setting visibility:hidden (not display)
    // keeps Angular's overlay refs valid so the app doesn't trip on missing
    // host elements.
    await page.addInitScript(() => {
      const style = document.createElement('style');
      style.id = '__sp-screenshot-tooltip-suppress';
      style.textContent = `
        mat-tooltip-component,
        .mat-mdc-tooltip,
        .cdk-overlay-container .mat-mdc-tooltip,
        .cdk-overlay-container .mat-tooltip,
        .cdk-overlay-container [role="tooltip"] {
          visibility: hidden !important;
          opacity: 0 !important;
        }
      `;
      const attach = (): void => {
        if (!document.getElementById(style.id)) document.head.appendChild(style);
      };
      if (document.head) attach();
      else document.addEventListener('DOMContentLoaded', attach, { once: true });
    });

    page.on('pageerror', (err) => {
      console.error('[screenshot pageerror]', err.message);
    });

    await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
    await waitForAppReady(page);

    try {
      await use(page);
    } finally {
      if (!page.isClosed()) await page.close();
      await context.close();
    }
  },

  seededPage: async ({ page, seedFile }, use) => {
    const importPage = new ImportPage(page);
    await importPage.navigateToImportPage();
    await importPage.importBackupFile(seedFile);
    await waitForAppReady(page);
    await use(page);
  },

  screenshotMaster: async ({ page, electronApp, locale, theme }, use, testInfo) => {
    const viewport = testInfo.project.name as ViewportName;
    const showSyncReadyState = async (): Promise<void> => {
      await page.evaluate(() => {
        const syncBtn = document.querySelector('.sync-btn');
        if (!syncBtn || syncBtn.querySelector('.__sp-sync-ready-check')) return;

        const icon = document.createElement('span');
        icon.className =
          'mat-icon notranslate material-icons mat-ligature-font mat-icon-no-color sync-state-ico __sp-sync-ready-check';
        icon.setAttribute('aria-hidden', 'true');
        icon.textContent = 'check';
        syncBtn.appendChild(icon);
      });
    };
    const fn = async (scenario: string, name: string): Promise<void> => {
      // Read live theme + locale from the page so one test can span multiple
      // (locale × theme) variants. helpers.applyTheme flips DARK_MODE in
      // localStorage; helpers.applyLocale stamps the current locale on
      // `window.__spCurrentLocale` (set initially in fixture init scripts).
      // Both fall back to the test.use() values if the lookup fails.
      const live = await page
        .evaluate(() => ({
          theme: localStorage.getItem('DARK_MODE'),
          locale: (window as unknown as { __spCurrentLocale?: string }).__spCurrentLocale,
        }))
        .catch(() => ({ theme: null as string | null, locale: undefined }));
      const currentTheme =
        live.theme === 'light' || live.theme === 'dark' ? live.theme : theme;
      const currentLocale =
        typeof live.locale === 'string' && live.locale ? live.locale : locale;
      const dir = path.join(MASTER_DIR, viewport, currentLocale, currentTheme, scenario);
      fs.mkdirSync(dir, { recursive: true });
      const file = path.join(dir, `${name}.png`);
      // Park the cursor at (0,0) so any Material tooltip from the last click
      // dismisses (matTooltip hides on mouseleave). Also any cdk-overlay
      // tooltip that's already open is force-removed via the screenshot CSS.
      try {
        await showSyncReadyState();
        await page.mouse.move(0, 0);
        await page.waitForTimeout(120);
      } catch {
        /* electron pages with no mouse host shouldn't ever fail this */
      }
      if (MODE === 'electron' && electronApp) {
        await captureWindowWithChrome(electronApp, page, file);
      } else {
        await page.screenshot({
          path: file,
          type: 'png',
          fullPage: false,
          animations: 'disabled',
          caret: 'hide',
        });
      }
    };
    await use(fn);
  },
});

export { expect } from '@playwright/test';
