/* eslint-disable no-console */
const http = require("http");
const net = require("net");
const fs = require("fs/promises");
const path = require("path");
const { spawn } = require("child_process");

function loadPuppeteer() {
  try {
    return require("puppeteer");
  } catch (localError) {
    try {
      return require(require.resolve("puppeteer", {
        paths: [path.resolve(__dirname, "../../../jumping-game")],
      }));
    } catch {
      throw new Error(
        "Puppeteer is required for the visual e2e test. Install it in this repo or keep the sibling jumping-game dependency available."
      );
    }
  }
}

const puppeteer = loadPuppeteer();

const ROOT_DIR = path.resolve(__dirname, "../..");
const HOST = "127.0.0.1";
const MAX_WAIT_MS = 90000;
const SERVER_WAIT_ATTEMPTS = 120;

const VIEWPORTS = [
  { name: "small-desktop-1366x768", width: 1366, height: 768 },
  { name: "standard-desktop-1440x900", width: 1440, height: 900 },
  { name: "large-desktop-1920x1080", width: 1920, height: 1080 },
  { name: "xl-desktop-2560x1440", width: 2560, height: 1440 },
  { name: "ultrawide-desktop-2560x1080", width: 2560, height: 1080 },
  { name: "tablet-landscape-touch-1024x768", width: 1024, height: 768, hasTouch: true },
  { name: "tablet-portrait-touch-768x1024", width: 768, height: 1024, hasTouch: true },
];

const PHASES = [
  {
    id: "phase-0-choose-flight-controls",
    label: "Phase 0 - Choose Flight Controls",
    title: "Choose Flight Controls",
    requiresCanvas: true,
    settleMs: 1600,
  },
  {
    id: "phase-1-collect-10-stars",
    label: "Phase 1 - Collect 10 Stars",
    title: "Collect 10 Stars",
    settleMs: 600,
  },
  {
    id: "mission-ready",
    label: "Mission Ready",
    title: "Mission Ready",
    requiresCanvas: true,
    settleMs: 1600,
  },
];

function timestampSlug(date = new Date()) {
  return date.toISOString().replace(/[:.]/g, "-");
}

function parseArgs(argv) {
  const args = {
    baseUrl: process.env.ROBOFLY_GAME_VISUAL_BASE_URL || "",
    headed: false,
    outputDir: path.join(
      ROOT_DIR,
      "visual-reports",
      `robofly-game-phases-${timestampSlug()}`
    ),
    viewportNames: [],
  };

  for (const arg of argv) {
    if (arg === "--headed") {
      args.headed = true;
    } else if (arg.startsWith("--base-url=")) {
      args.baseUrl = arg.slice("--base-url=".length).replace(/\/$/, "");
    } else if (arg.startsWith("--output-dir=")) {
      args.outputDir = path.resolve(ROOT_DIR, arg.slice("--output-dir=".length));
    } else if (arg.startsWith("--viewport=")) {
      args.viewportNames.push(
        ...arg
          .slice("--viewport=".length)
          .split(",")
          .map((value) => value.trim())
          .filter(Boolean)
      );
    } else {
      throw new Error(`Unknown argument: ${arg}`);
    }
  }

  return args;
}

function selectViewports(viewportNames) {
  if (!viewportNames.length) {
    return VIEWPORTS;
  }

  return viewportNames.map((name) => {
    const viewport = VIEWPORTS.find((candidate) => candidate.name === name);
    if (!viewport) {
      throw new Error(
        `Unknown viewport "${name}". Available viewports: ${VIEWPORTS.map(
          (candidate) => candidate.name
        ).join(", ")}`
      );
    }
    return viewport;
  });
}

function findOpenPort() {
  return new Promise((resolve, reject) => {
    const tester = net.createServer();
    tester.unref();
    tester.on("error", reject);
    tester.listen(0, HOST, () => {
      const address = tester.address();
      if (!address || typeof address === "string") {
        tester.close(() => reject(new Error("Could not determine an open port.")));
        return;
      }
      tester.close(() => resolve(address.port));
    });
  });
}

function startServer(port) {
  const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
  const server = spawn(npmCommand, ["start"], {
    cwd: ROOT_DIR,
    detached: process.platform !== "win32",
    env: {
      ...process.env,
      BROWSER: "none",
      CI: "false",
      PORT: `${port}`,
    },
    stdio: ["ignore", "pipe", "pipe"],
  });

  const output = [];
  const store = (chunk) => {
    const text = chunk.toString();
    output.push(text);
    if (output.length > 80) {
      output.splice(0, output.length - 80);
    }
  };

  server.stdout.on("data", store);
  server.stderr.on("data", store);
  server.recentOutput = () => output.join("");

  return server;
}

function stopServer(server) {
  if (!server || server.killed) {
    return;
  }

  try {
    if (process.platform === "win32") {
      server.kill();
      return;
    }
    process.kill(-server.pid, "SIGTERM");
  } catch {
    try {
      server.kill();
    } catch {
      // Ignore cleanup failures during shutdown.
    }
  }
}

function isExpectedAppResponse(body) {
  return body.includes("<div id=\"root\"></div>") || body.includes("<div id=\"root\">");
}

function waitForServer(url, server) {
  return new Promise((resolve, reject) => {
    let attempts = 0;

    const check = () => {
      const req = http.get(url, (res) => {
        let body = "";
        res.setEncoding("utf8");
        res.on("data", (chunk) => {
          body += chunk;
        });
        res.on("end", () => {
          if (res.statusCode === 200 && isExpectedAppResponse(body)) {
            resolve();
            return;
          }
          retry();
        });
      });

      req.on("error", retry);
      req.setTimeout(3000, () => {
        req.destroy();
        retry();
      });
    };

    const retry = () => {
      attempts += 1;
      if (attempts >= SERVER_WAIT_ATTEMPTS) {
        reject(
          new Error(
            `Server at ${url} did not serve the app shell.\n${server && server.recentOutput ? server.recentOutput() : ""}`
          )
        );
        return;
      }
      setTimeout(check, 1000);
    };

    check();
  });
}

async function waitForIdle(page, timeout = 1000) {
  if (typeof page.waitForTimeout === "function") {
    await page.waitForTimeout(timeout);
    return;
  }

  await new Promise((resolve) => setTimeout(resolve, timeout));
}

async function createPage(browser) {
  const page = await browser.newPage();
  page.setDefaultTimeout(MAX_WAIT_MS);
  page.setDefaultNavigationTimeout(MAX_WAIT_MS);

  await page.evaluateOnNewDocument(() => {
    window.confirm = () => true;
    const originalPlay = window.HTMLMediaElement && window.HTMLMediaElement.prototype.play;
    if (originalPlay) {
      window.HTMLMediaElement.prototype.play = function playWithoutVisualOverlay() {
        const result = originalPlay.apply(this, arguments);
        if (result && typeof result.catch === "function") {
          return result.catch(() => undefined);
        }
        return result;
      };
    }
  });

  const consoleMessages = [];
  const pageErrors = [];

  page.on("console", (msg) => {
    const type = msg.type();
    if (type === "error" || type === "warning") {
      consoleMessages.push({ type, text: msg.text() });
    }
  });

  page.on("pageerror", (err) => {
    pageErrors.push(err.message || String(err));
  });

  page.on("dialog", async (dialog) => {
    try {
      await dialog.accept();
    } catch {
      // Ignore dialogs that close before Puppeteer can accept them.
    }
  });

  return { page, consoleMessages, pageErrors };
}

async function setViewport(page, viewport) {
  await page.setViewport({
    width: viewport.width,
    height: viewport.height,
    isMobile: Boolean(viewport.isMobile),
    hasTouch: Boolean(viewport.hasTouch),
    deviceScaleFactor: 1,
  });
}

async function waitForBodyText(page, text, timeout = MAX_WAIT_MS) {
  await page.waitForFunction(
    (expectedText) => {
      const bodyText = document.body ? document.body.innerText || "" : "";
      return bodyText.includes(expectedText);
    },
    { timeout },
    text
  );
}

async function waitForPhaseReady(page, phase) {
  if (phase.id === "mission-ready") {
    await waitForMissionReady(page);
  } else {
    await waitForBodyText(page, phase.title);
  }

  if (phase.requiresCanvas) {
    await page.waitForSelector("canvas", { visible: true, timeout: MAX_WAIT_MS });
  }

  await waitForIdle(page, phase.settleMs || 1000);
}

async function waitForMissionReady(page) {
  const tutorialTitles = PHASES.filter((phase) => phase.id !== "mission-ready").map(
    (phase) => phase.title
  );

  await page.waitForFunction(
    (titles) => {
      const bodyText = document.body ? document.body.innerText || "" : "";
      return (
        bodyText.includes("0/10") &&
        bodyText.includes("Press") &&
        !titles.some((title) => bodyText.includes(title)) &&
        document.querySelectorAll("canvas").length === 1
      );
    },
    { timeout: MAX_WAIT_MS },
    tutorialTitles
  );
}

async function clickVisibleButton(page, predicateSource, predicateArg) {
  const clicked = await page.evaluate(
    ({ source, arg }) => {
      const predicate = new Function("button", "arg", source);
      const buttons = Array.from(document.querySelectorAll("button"));
      const button = buttons.find((candidate) => {
        const rect = candidate.getBoundingClientRect();
        const style = window.getComputedStyle(candidate);
        const isVisible =
          rect.width > 0 &&
          rect.height > 0 &&
          style.visibility !== "hidden" &&
          style.display !== "none" &&
          !candidate.disabled;
        return isVisible && predicate(candidate, arg);
      });

      if (!button) {
        return false;
      }

      button.click();
      return true;
    },
    { source: predicateSource, arg: predicateArg }
  );

  if (!clicked) {
    throw new Error(`Could not find visible enabled button for ${predicateArg}.`);
  }
}

async function clickButtonWithText(page, text) {
  await clickVisibleButton(
    page,
    "return (button.textContent || '').replace(/\\s+/g, ' ').trim() === arg;",
    text
  );
}

async function assertMainGameDoesNotStartBeforeTutorialFinished(page) {
  const before = await page.evaluate(() => {
    const mainCanvas = Array.from(document.querySelectorAll("canvas")).find(
      (canvas) => !canvas.closest("[class*=overlay]")
    );
    if (!mainCanvas) {
      return null;
    }
    const rect = mainCanvas.getBoundingClientRect();
    return {
      dataUrl: mainCanvas.toDataURL("image/png"),
      rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
    };
  });

  if (!before) {
    throw new Error("Could not find the main game canvas behind the tutorial.");
  }

  await page.mouse.click(
    before.rect.x + before.rect.width * 0.75,
    before.rect.y + before.rect.height * 0.55
  );
  await waitForIdle(page, 1200);

  const afterDataUrl = await page.evaluate(() => {
    const mainCanvas = Array.from(document.querySelectorAll("canvas")).find(
      (canvas) => !canvas.closest("[class*=overlay]")
    );
    return mainCanvas ? mainCanvas.toDataURL("image/png") : "";
  });

  if (before.dataUrl !== afterDataUrl) {
    throw new Error("Main game canvas changed before tutorial-finished was emitted.");
  }
}

async function completeChooseFlightControls(page) {
  await assertMainGameDoesNotStartBeforeTutorialFinished(page);
  await clickButtonWithText(page, "Continue");
  await waitForBodyText(page, "Collect 10 Stars");
}

async function completeCollect10Stars(page) {
  await waitForBodyText(page, "Collect 10 Stars");
  await waitForMissionReady(page);
}

async function advanceFromPhase(page, phase) {
  switch (phase.id) {
    case "phase-0-choose-flight-controls":
      await completeChooseFlightControls(page);
      return;
    case "phase-1-collect-10-stars":
      await completeCollect10Stars(page);
      return;
    case "mission-ready":
      return;
    default:
      throw new Error(`No phase advancement handler for ${phase.id}.`);
  }
}

function safeFileName(value) {
  return String(value)
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

async function capturePhase(page, outputDir, viewport, phase) {
  const screenshotRel = path.join(
    "screenshots",
    viewport.name,
    `${safeFileName(phase.id)}.png`
  );
  const screenshotPath = path.join(outputDir, screenshotRel);
  const entry = {
    id: phase.id,
    label: phase.label,
    viewport: viewport.name,
    viewportSize: { width: viewport.width, height: viewport.height },
    status: "captured",
    screenshot: screenshotRel,
    currentUrl: page.url(),
  };

  try {
    await waitForPhaseReady(page, phase);
    await fs.mkdir(path.dirname(screenshotPath), { recursive: true });
    await page.screenshot({ path: screenshotPath, fullPage: false });
    entry.currentUrl = page.url();
  } catch (err) {
    entry.status = "error";
    entry.screenshot = null;
    entry.error = err && err.message ? err.message : String(err);
    entry.currentUrl = page.url();
  }

  return entry;
}

function createErrorEntry(viewport, phase, err, page) {
  return {
    id: phase.id,
    label: phase.label,
    viewport: viewport.name,
    viewportSize: { width: viewport.width, height: viewport.height },
    status: "error",
    screenshot: null,
    error: err && err.message ? err.message : String(err),
    currentUrl: page ? page.url() : null,
  };
}

async function captureViewport(browser, baseUrl, outputDir, viewport) {
  const { page, consoleMessages, pageErrors } = await createPage(browser);
  const entries = [];

  try {
    await setViewport(page, viewport);
    await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: MAX_WAIT_MS });

    for (let index = 0; index < PHASES.length; index += 1) {
      const phase = PHASES[index];
      console.log(`Capturing ${phase.label} at ${viewport.name}`);
      const entry = await capturePhase(page, outputDir, viewport, phase);
      entries.push(entry);

      if (entry.status !== "captured") {
        break;
      }

      if (index < PHASES.length - 1) {
        await advanceFromPhase(page, phase);
      }
    }
  } catch (err) {
    const capturedIds = new Set(entries.map((entry) => entry.id));
    const remainingPhases = PHASES.filter((phase) => !capturedIds.has(phase.id));
    for (const phase of remainingPhases) {
      entries.push(createErrorEntry(viewport, phase, err, page));
    }
  } finally {
    if (!page.isClosed()) {
      await page.close().catch(() => {});
    }
  }

  return { entries, consoleMessages, pageErrors };
}

function summarizeEntries(entries) {
  return entries.reduce(
    (summary, entry) => {
      summary[entry.status] = (summary[entry.status] || 0) + 1;
      return summary;
    },
    { captured: 0, error: 0, skipped: 0 }
  );
}

async function writeManifest(outputDir, manifest) {
  await fs.mkdir(outputDir, { recursive: true });
  await fs.writeFile(
    path.join(outputDir, "manifest.json"),
    `${JSON.stringify(manifest, null, 2)}\n`,
    "utf8"
  );
}

function escapeHtml(value) {
  return String(value == null ? "" : value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function renderEntry(entry) {
  const title = `${entry.label} - ${entry.viewport}`;
  const meta = `${entry.viewportSize.width}x${entry.viewportSize.height}`;

  if (entry.status === "captured") {
    return `
      <article class="card captured">
        <h2>${escapeHtml(title)}</h2>
        <p class="meta">${escapeHtml(meta)}</p>
        <a href="${escapeHtml(entry.screenshot)}"><img src="${escapeHtml(entry.screenshot)}" alt="${escapeHtml(title)}"></a>
      </article>
    `;
  }

  return `
    <article class="card error">
      <h2>${escapeHtml(title)}</h2>
      <p class="meta">${escapeHtml(meta)}</p>
      <p><strong>${escapeHtml(entry.status.toUpperCase())}</strong></p>
      ${entry.error ? `<pre>${escapeHtml(entry.error)}</pre>` : ""}
    </article>
  `;
}

async function writeIndex(outputDir, manifest) {
  const byViewport = manifest.entries.reduce((groups, entry) => {
    if (!groups[entry.viewport]) {
      groups[entry.viewport] = [];
    }
    groups[entry.viewport].push(entry);
    return groups;
  }, {});

  const sections = manifest.viewports
    .filter((viewport) => byViewport[viewport.name])
    .map(
      (viewport) => `
        <section>
          <h1>${escapeHtml(viewport.name)} <span>${viewport.width}x${viewport.height}</span></h1>
          <div class="grid">
            ${byViewport[viewport.name].map(renderEntry).join("\n")}
          </div>
        </section>
      `
    )
    .join("\n");

  const diagnostics = manifest.browserDiagnostics.pageErrors.length
    ? `<section><h1>Page Errors</h1><pre>${escapeHtml(
        manifest.browserDiagnostics.pageErrors.join("\n")
      )}</pre></section>`
    : "";

  const html = `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Robofly Game Visual Phase Review</title>
  <style>
    body {
      margin: 0;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f5f7f8;
      color: #142528;
    }
    header {
      padding: 24px 32px;
      background: #123c46;
      color: #ffffff;
    }
    header h1 {
      margin: 0 0 8px;
      font-size: 28px;
    }
    header p {
      margin: 4px 0;
    }
    main {
      padding: 24px 32px 48px;
    }
    section {
      margin-bottom: 32px;
    }
    section > h1 {
      font-size: 22px;
      margin: 0 0 16px;
    }
    section > h1 span {
      color: #52666c;
      font-size: 15px;
      font-weight: 500;
    }
    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
      gap: 16px;
    }
    .card {
      background: #ffffff;
      border: 1px solid #d9e1e4;
      border-radius: 6px;
      overflow: hidden;
      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
    }
    .card h2 {
      margin: 0;
      padding: 14px 16px 4px;
      font-size: 16px;
    }
    .meta {
      margin: 0;
      padding: 0 16px 12px;
      color: #52666c;
      font-size: 13px;
    }
    .card img {
      display: block;
      width: 100%;
      border-top: 1px solid #d9e1e4;
    }
    .error {
      padding-bottom: 16px;
    }
    .error p,
    pre {
      margin: 8px 16px;
    }
    pre {
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      background: #f1f4f5;
      padding: 12px;
      border-radius: 4px;
    }
  </style>
</head>
<body>
  <header>
    <h1>Robofly Game Visual Phase Review</h1>
    <p>Generated at ${escapeHtml(manifest.generatedAt)}</p>
    <p>Captured: ${manifest.summary.captured} | Errors: ${manifest.summary.error}</p>
    <p>Base URL: ${escapeHtml(manifest.baseUrl)}</p>
  </header>
  <main>
    ${diagnostics}
    ${sections}
  </main>
</body>
</html>
`;

  await fs.writeFile(path.join(outputDir, "index.html"), html, "utf8");
}

async function run() {
  const args = parseArgs(process.argv.slice(2));
  const viewports = selectViewports(args.viewportNames);
  let server = null;
  let baseUrl = args.baseUrl;
  let browser = null;

  try {
    if (!baseUrl) {
      const port = await findOpenPort();
      baseUrl = `http://${HOST}:${port}`;
      console.log(`Starting robofly-game dev server at ${baseUrl}`);
      server = startServer(port);
      await waitForServer(baseUrl, server);
      console.log(`Dev server ready at ${baseUrl}`);
    } else {
      console.log(`Using existing server at ${baseUrl}`);
    }

    console.log(`Writing visual phase output to ${args.outputDir}`);

    browser = await puppeteer.launch({
      headless: !args.headed,
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
      defaultViewport: { width: 1440, height: 900 },
      protocolTimeout: MAX_WAIT_MS,
    });

    const entries = [];
    const consoleMessages = [];
    const pageErrors = [];

    for (const viewport of viewports) {
      const result = await captureViewport(browser, baseUrl, args.outputDir, viewport);
      entries.push(...result.entries);
      consoleMessages.push(...result.consoleMessages);
      pageErrors.push(...result.pageErrors);
    }

    const manifest = {
      generatedAt: new Date().toISOString(),
      mode: "visual-phases",
      baseUrl,
      outputDir: args.outputDir,
      viewports,
      phases: PHASES,
      entries,
      browserDiagnostics: {
        consoleMessages,
        pageErrors,
      },
      summary: summarizeEntries(entries),
    };

    await writeManifest(args.outputDir, manifest);
    await writeIndex(args.outputDir, manifest);

    console.log("");
    console.log("Robofly game visual phase review complete:");
    console.log(`- Captured: ${manifest.summary.captured}`);
    console.log(`- Errors: ${manifest.summary.error}`);
    console.log(`- Gallery: ${path.join(args.outputDir, "index.html")}`);
    console.log(`- Manifest: ${path.join(args.outputDir, "manifest.json")}`);

    if (manifest.summary.error > 0) {
      process.exitCode = 1;
    }
  } finally {
    if (browser) {
      await browser.close().catch(() => {});
    }
    stopServer(server);
  }
}

run().catch((err) => {
  console.error(err && err.stack ? err.stack : err);
  process.exitCode = 1;
});
