const puppeteer = require("puppeteer-core");
const fs = require("fs");
const path = require('path');

(async () => {
  const t0 = Date.now();
  const ts = () => new Date().toISOString() + " +" + (Date.now() - t0) + "ms";
  const log = (...args) => console.log(ts(), ...args);
  log("Puppeteer test starting");
  // Create a per-run output directory under /workspace named YYMMDD-HHMMSS.
  const WORKSPACE_DIR = '/workspace';
  const makeRunDirName = (d) => {
    const yy = String(d.getFullYear()).slice(-2);
    const mm = String(d.getMonth() + 1).padStart(2, '0');
    const dd = String(d.getDate()).padStart(2, '0');
    const hh = String(d.getHours()).padStart(2, '0');
    const min = String(d.getMinutes()).padStart(2, '0');
    const ss = String(d.getSeconds()).padStart(2, '0');
    return `${yy}${mm}${dd}-${hh}${min}${ss}`;
  };
  // Allow the caller to provide a run directory name so external wrappers
  // (e.g., run_with_server_logs.sh) can place related artifacts together.
  const runDirName = process.env.TEST_RUN_DIR_NAME || makeRunDirName(new Date());
  // Place per-run outputs under /workspace/test-output/<runDir> so the
  // host-side wrapper (which creates ./test-output/<runDir>) and the
  // container-mounted workspace line up exactly.
  const OUT_DIR = path.join(WORKSPACE_DIR, 'test-output', runDirName);
  try {
    fs.mkdirSync(OUT_DIR, { recursive: true });
    log('Using output directory', OUT_DIR);
  } catch (e) {
    log('Failed to create output directory', OUT_DIR, e && e.message ? e.message : e);
  }
  let browser;
  // Global timeout (ms) for the whole test run to avoid infinite log floods.
  const MAX_TEST_MS = parseInt(process.env.TEST_MAX_MS || "60000", 10);
  let _globalTimeout = null;
  let _timedOut = false;
  const startGlobalTimeout = () => {
    if (_globalTimeout) return;
    _globalTimeout = setTimeout(async () => {
      _timedOut = true;
      try {
        log(`Global timeout of ${MAX_TEST_MS}ms reached — aborting test`);
        if (browser) {
          try {
            await browser.close();
            log('Browser closed due to global timeout');
          } catch (e) {
            log('Error while closing browser on timeout:', e && e.message ? e.message : e);
          }
        }
      } catch (e) {
        // swallow
      }
      // Exit with distinct code so CI can tell a timeout occurred.
      process.exit(124);
    }, MAX_TEST_MS);
  };
  const clearGlobalTimeout = () => {
    try {
      if (_globalTimeout) clearTimeout(_globalTimeout);
    } catch (e) {}
    _globalTimeout = null;
  };
  // Start the global timeout immediately so the whole test run (including
  // browser launch) is bounded by TEST_MAX_MS. This makes the timeout work
  // even when env vars are not propagated the way the caller expects.
  startGlobalTimeout();
  try {
    // Try to use a system Chrome/Chromium binary if present. This lets
    // developers skip downloading Chromium during `npm install` by setting
    // PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 and supplying CHROME_PATH.
    const possiblePaths = [];
    if (process.env.CHROME_PATH) possiblePaths.push(process.env.CHROME_PATH);
    possiblePaths.push(
      "/usr/bin/google-chrome-stable",
      "/usr/bin/google-chrome",
      "/usr/bin/chromium-browser",
      "/usr/bin/chromium"
    );
    let launchOptions = {
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
      ignoreHTTPSErrors: true,
    };
    for (const p of possiblePaths) {
      try {
        const stat = require("fs").statSync(p);
        if (stat && stat.isFile()) {
          log("Found system Chrome at", p, "— will use it");
          launchOptions.executablePath = p;
          break;
        }
      } catch (e) {
        // ignore
      }
    }

    log(
      "Launching Puppeteer with options:",
      Object.assign({}, launchOptions, {
        executablePath: launchOptions.executablePath
          ? launchOptions.executablePath
          : "<auto>",
      })
    );
    const tLaunch = Date.now();
    browser = await puppeteer.launch(launchOptions);
    log("Browser launched (took", Date.now() - tLaunch + "ms)");
    // Start the global timeout after browser is launched so we can reliably
    // close the browser if we need to abort.
    startGlobalTimeout();
    const page = await browser.newPage();
    log("New page created");

    // Intercept WebSocket.prototype.send as early as possible so we can
    // capture outgoing messages from the client (helps verify the name
    // submit handler triggered). Use evaluateOnNewDocument so interception
    // is in place before any page script runs.
    try {
      await page.evaluateOnNewDocument(() => {
        try {
          const orig = WebSocket.prototype.send;
          WebSocket.prototype.send = function (data) {
            try {
              window.__wsSends = window.__wsSends || [];
              let d = data;
              // Handle ArrayBuffer or typed arrays by attempting to decode
              if (d instanceof ArrayBuffer || ArrayBuffer.isView(d)) {
                try {
                  d = new TextDecoder().decode(d);
                } catch (e) {
                  d = '' + d;
                }
              }
              const entry = { ts: Date.now(), data: typeof d === 'string' ? d : (d && d.toString ? d.toString() : JSON.stringify(d)) };
              window.__wsSends.push(entry);
              // Emit a console log so the host log captures the message body
              try { console.log('WS_SEND_INTERCEPT', entry.data); } catch (e) {}
            } catch (e) {
              // swallow
            }
            return orig.apply(this, arguments);
          };
        } catch (e) {
          // swallow
        }
      });
      log('Installed WebSocket send interceptor (evaluateOnNewDocument)');
    } catch (e) {
      log('Could not install WS interceptor:', e && e.message ? e.message : e);
    }

    // forward page console messages to our logs (helpful to see runtime errors)
    page.on('console', msg => {
      try {
        const args = msg.args ? msg.args.map(a => a.toString()).join(' ') : msg.text();
        log('PAGE_CONSOLE', msg.type(), args);
      } catch (e) {
        log('PAGE_CONSOLE', msg.type(), msg.text());
      }
    });
    page.on('requestfailed', req => {
      try { log('REQUEST_FAILED', req.url(), req.failure && req.failure().errorText); } catch (e) { log('REQUEST_FAILED', req.url()); }
    });

    // track simple network counts so we can see if navigation triggers many requests
    let reqCount = 0,
      resCount = 0;
    page.on("request", (r) => {
      reqCount++;
    });
    page.on("response", (r) => {
      resCount++;
    });

    // small helper to replace the deprecated page.waitForTimeout
    const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

    // Navigate to the dev client; allow overriding via CLI or env
    const url =
      process.argv[2] ||
      process.env.TEST_URL ||
      "https://localhost:3001/ketr.ketran/";
    log("Navigating to", url);
    try {
      log("Calling page.goto (domcontentloaded, 10s timeout)");
      const tNavStart = Date.now();
      await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
      log(
        "Page load (domcontentloaded) finished (took",
        Date.now() - tNavStart + "ms)",
        "requests=",
        reqCount,
        "responses=",
        resCount
      );

      // Diagnostic snapshot: save the full HTML and a screenshot immediately
      // after domcontentloaded so we can inspect the initial page state even
      // when the inputs appear much later.
        try {
          const domContent = await page.content();
          try {
            const outPath = path.join(OUT_DIR, 'domcontent.html');
            fs.writeFileSync(outPath, domContent);
            log('Saved domcontent HTML to', outPath, '(length', domContent.length + ')');
          } catch (e) {
            log('Failed to write domcontent HTML:', e && e.message ? e.message : e);
          }
        } catch (e) {
          log('Could not read page content at domcontentloaded:', e && e.message ? e.message : e);
        }
      try {
  const shotNow = path.join(OUT_DIR, 'domcontent.png');
  await page.screenshot({ path: shotNow, fullPage: true });
  log('Saved domcontent screenshot to', shotNow);
      } catch (e) {
        log('Failed to save domcontent screenshot:', e && e.message ? e.message : e);
      }
      try {
        const immediateInputs = await page.evaluate(() => document.querySelectorAll('input').length);
        log('Immediate input count at domcontentloaded =', immediateInputs);
      } catch (e) {
        log('Could not evaluate immediate input count:', e && e.message ? e.message : e);
      }

      // Install a MutationObserver inside the page to record the first time any
      // input element appears. We store the epoch ms in window.__inputsFirstSeen
      // and also emit a console log so the test logs capture the exact instant.
      try {
        await page.evaluate(() => {
          try {
            window.__inputsFirstSeen = null;
            const check = () => {
              try {
                const count = document.querySelectorAll('input').length;
                if (count && window.__inputsFirstSeen === null) {
                  window.__inputsFirstSeen = Date.now();
                  // eslint-disable-next-line no-console
                  console.log('INPUTS_APPEARED', window.__inputsFirstSeen, 'count=', count);
                  if (observer) observer.disconnect();
                }
              } catch (e) {
                // swallow
              }
            };
            const observer = new MutationObserver(check);
            observer.observe(document.body || document.documentElement, {
              childList: true,
              subtree: true,
              attributes: true,
            });
            // Run an initial check in case inputs are already present
            check();
          } catch (e) {
            // ignore page-side errors
          }
        });
        log('MutationObserver probe installed to detect first input appearance');
      } catch (e) {
        log('Could not install MutationObserver probe:', e && e.message ? e.message : e);
      }
    } catch (e) {
      console.error("Failed to load page:", e && e.message ? e.message : e);
      // Print a short sniff of the response HTML if available
      try {
        const content = await page.content();
        console.log("Page content snippet:\n", content.slice(0, 1000));
      } catch (err) {
        console.warn(
          "Could not read page content:",
          err && err.message ? err.message : err
        );
      }
      throw e;
    }

    // Fast-path: wait for a visible input quickly. If that fails, fall back to polling.
    let inputsFound = 0;
    try {
      log('Fast-wait for visible input (5s)');
      await page.waitForSelector('input', { visible: true, timeout: 5000 });
      inputsFound = await page.evaluate(() => document.querySelectorAll('input').length);
      log('Fast-wait success, inputsFound=', inputsFound);
    } catch (e) {
      log('Fast-wait failed (no visible input within 5s), falling back to polling');
      // poll for inputs so we know exactly when they appear rather than sleeping blindly
      const pollStart = Date.now();
      for (let i = 0; i < 80; i++) { // 80 * 250ms = 20s fallback
        try {
          inputsFound = await page.evaluate(() => document.querySelectorAll('input').length);
        } catch (err) {
          log('evaluate error while polling inputs:', err && err.message ? err.message : err);
        }
        // Abort the polling if the global timeout has already fired or is about to.
        if (_timedOut) {
          log('Aborting input polling because global timeout flag is set');
          break;
        }
        if (Date.now() - t0 > MAX_TEST_MS - 2000) {
          log('Approaching global timeout while polling inputs — aborting polling loop');
          break;
        }
        if (inputsFound && inputsFound > 0) {
          log('Inputs appeared after', (Date.now() - pollStart) + 'ms; count=', inputsFound);
          break;
        }
        if (i % 20 === 0) log('still polling for inputs...', i, 'iterations, elapsed', (Date.now() - pollStart) + 'ms');
        await sleep(250);
      }
      if (!inputsFound) log('No inputs found after fallback polling (~20s)');
    }

    // If the app shows an "Enter your name" prompt, robustly try to fill it
    try {
      log('Attempting to detect name input by placeholder/aria/label/id or first visible input');
      const candidates = [
        "input[placeholder*='Enter your name']",
        "input[placeholder*='enter your name']",
        "input[aria-label*='name']",
        "input[name='name']",
        "input[id*='name']",
        ".nameInput input",
        // label-based XPath (will be handled specially below)
        "xpath://label[contains(normalize-space(.), 'Enter your name')]/following::input[1]",
        "xpath://*[contains(normalize-space(.), 'Enter your name')]/following::input[1]",
        "input"
      ];
      let filled = false;
      for (const sel of candidates) {
        try {
          let el = null;
          if (sel.startsWith('xpath:')) {
            const path = sel.substring(6);
            const nodes = await page.$x(path);
            if (nodes && nodes.length) el = nodes[0];
          } else {
            el = await page.$(sel);
          }
          if (el) {
            await el.focus();
            const tTypeStart = Date.now();
            await page.keyboard.type('Automaton', { delay: 50 });
            log('Typed name via selector', sel, '(took', Date.now() - tTypeStart + 'ms)');
            // Press Enter to submit if the UI responds to it
            await page.keyboard.press('Enter');
            filled = true;
            await sleep(500);
            break;
          }
        } catch (inner) {
          // ignore selector-specific failures
        }
      }

      if (!filled) {
        // fallback: find the first visible input and type there
        const inputs = await page.$$('input');
        for (const input of inputs) {
          try {
            const box = await input.boundingBox();
            if (box) {
              await input.focus();
              const tTypeStart = Date.now();
              await page.keyboard.type('Automaton', { delay: 50 });
              await page.keyboard.press('Enter');
              log('Typed name into first visible input (took', Date.now() - tTypeStart + 'ms)');
              filled = true;
              await sleep(500);
              break;
            }
          } catch (inner) {}
        }
      }

      // Try clicking a button to confirm/join. Match common labels case-insensitively.
      const clickTexts = ['set','join','ok','enter','start','confirm'];
      let clicked = false;
      for (const txt of clickTexts) {
        try {
          const xpath = `//button[contains(translate(normalize-space(.),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'${txt}')]`;
          const btns = await page.$x(xpath);
          if (btns && btns.length) {
            await btns[0].click();
            log('Clicked button matching text', txt);
            clicked = true;
            await sleep(500);
            break;
          }
        } catch (inner) {}
      }
      if (!clicked) {
        // fallback: click submit-type inputs/buttons
        const submit = await page.$("input[type=submit], button[type=submit]");
        if (submit) {
          await submit.click();
          log('Clicked submit control');
          clicked = true;
          await sleep(500);
        }
      }

      if (!filled && !clicked) log('No name input or submit button found to set name');
    } catch (e) {
      log('Could not auto-enter name:', e && e.message ? e.message : e);
    }

    // Read the MutationObserver probe timestamp (if it recorded when inputs first appeared)
    try {
      const firstSeen = await page.evaluate(() => window.__inputsFirstSeen || null);
      if (firstSeen) {
        const delta = Date.now() - firstSeen;
        log('Probe: inputs first seen at', new Date(firstSeen).toISOString(), `(ago ${delta}ms)`);
        // Save a screenshot named with the first-seen timestamp to make it easy to correlate
  const shotPath = path.join(OUT_DIR, 'house-rules.png');
        try {
          await page.screenshot({ path: shotPath, fullPage: true });
          log('Saved input-appearance screenshot to', shotPath);
        } catch (e) {
          log('Failed to save input-appearance screenshot:', e && e.message ? e.message : e);
        }
        // Also save the full HTML at the time inputs first appeared so we can
        // inspect the DOM (some UI frameworks render inputs late).
        try {
          const html = await page.content();
          const outPath = path.join(OUT_DIR, 'domcontent-after-inputs.html');
          try {
            fs.writeFileSync(outPath, html);
            log('Saved DOM HTML at input-appearance to', outPath, '(length', html.length + ')');
          } catch (e) {
            log('Failed to write post-input DOM HTML:', e && e.message ? e.message : e);
          }
          // Also capture any intercepted WebSocket sends recorded by the
          // in-page interceptor and dump them to a JSON file for later
          // verification.
          try {
            const sends = await page.evaluate(() => window.__wsSends || []);
            const wsOut = path.join(OUT_DIR, 'ws-sends.json');
            try {
              fs.writeFileSync(wsOut, JSON.stringify(sends, null, 2));
              log('Saved intercepted WS sends to', wsOut, '(count', (sends && sends.length) + ')');
              // QUICK ASSERTION: ensure a player-name send with Automaton was captured
              try {
                const parsed = (sends || []).map(s => {
                  try { return JSON.parse(s.data); } catch (e) { return null; }
                }).filter(Boolean);
                const hasPlayerName = parsed.some(p => p.type === 'player-name' && ((p.data && p.data.name) === 'Automaton' || (p.name === 'Automaton')));
                if (!hasPlayerName) {
                  log('ASSERTION FAILED: No player-name send with name=Automaton found in intercepted WS sends');
                  // Write a failure marker file for CI to pick up
                  try { fs.writeFileSync(path.join(OUT_DIR, 'assert-failed-player-name.txt'), 'Missing player-name Automaton'); } catch (e) {}
                  process.exit(3);
                } else {
                  log('Assertion passed: player-name Automaton was sent by client');
                }
              } catch (e) {
                log('Error while asserting WS sends:', e && e.message ? e.message : e);
              }
            } catch (e) {
              log('Failed to write intercepted WS sends file:', e && e.message ? e.message : e);
            }
          } catch (e) {
            log('Could not read intercepted WS sends from page:', e && e.message ? e.message : e);
          }
        } catch (e) {
          log('Could not read page content at input-appearance:', e && e.message ? e.message : e);
        }
      } else {
        log('Probe did not record input first-seen timestamp (window.__inputsFirstSeen is null)');
      }
    } catch (e) {
      log('Could not read window.__inputsFirstSeen:', e && e.message ? e.message : e);
    }

    // Ensure we still capture intercepted WS sends even if the probe didn't
    // record the input-appearance timestamp. Dump a latest copy and run the
    // same assertion so CI will fail fast when player-name is missing.
    try {
      const sends = await page.evaluate(() => window.__wsSends || []);
  const outLatest = path.join(OUT_DIR, 'ws-sends-latest.json');
      try {
        fs.writeFileSync(outLatest, JSON.stringify(sends, null, 2));
        log('Saved intercepted WS sends (latest) to', outLatest, '(count', (sends && sends.length) + ')');
      } catch (e) {
        log('Failed to write latest intercepted WS sends file:', e && e.message ? e.message : e);
      }
      // Produce a small summary JSON for quick CI checks: total count, time range,
      // counts per message type.
      try {
        const parsed = (sends || []).map(s => {
          try { return JSON.parse(s.data); } catch (e) { return null; }
        }).filter(Boolean);
        const typesCount = {};
        let minTs = null, maxTs = null;
        (sends || []).forEach(s => {
          if (s && s.ts) {
            if (minTs === null || s.ts < minTs) minTs = s.ts;
            if (maxTs === null || s.ts > maxTs) maxTs = s.ts;
          }
        });
        parsed.forEach(p => {
          const t = p.type || 'unknown';
          typesCount[t] = (typesCount[t] || 0) + 1;
        });
        const summary = {
          runDir: runDirName,
          totalMessages: (sends || []).length,
          types: typesCount,
          tsRange: minTs !== null ? { min: minTs, max: maxTs } : null,
          generatedAt: Date.now()
        };
        const summaryPath = path.join(OUT_DIR, 'summary.json');
        try {
          fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
          log('Wrote summary JSON to', summaryPath);
        } catch (e) {
          log('Failed to write summary JSON:', e && e.message ? e.message : e);
        }
      } catch (e) {
        log('Could not generate summary.json:', e && e.message ? e.message : e);
      }
      // Run the same assertion against the latest sends if a per-timestamp
      // file was not written earlier.
      try {
        const parsed = (sends || []).map(s => {
          try { return JSON.parse(s.data); } catch (e) { return null; }
        }).filter(Boolean);
        const hasPlayerName = parsed.some(p => p.type === 'player-name' && ((p.data && p.data.name) === 'Automaton' || (p.name === 'Automaton')));
        if (!hasPlayerName) {
          log('ASSERTION FAILED: No player-name send with name=Automaton found in intercepted WS sends (latest)');
          try { fs.writeFileSync(path.join(OUT_DIR, 'assert-failed-player-name.txt'), 'Missing player-name Automaton'); } catch (e) {}
          process.exit(3);
        } else {
          log('Assertion passed (latest): player-name Automaton was sent by client');
        }
      } catch (e) {
        log('Error while asserting WS sends (latest):', e && e.message ? e.message : e);
      }
    } catch (e) {
      log('Could not read intercepted WS sends for latest dump:', e && e.message ? e.message : e);
    }

    // If the global timeout fired while we were running, abort now.
    if (_timedOut) {
      log('Test aborted due to global timeout flag set after probe check');
      if (browser) try { await browser.close(); } catch (e) {}
      process.exit(124);
    }

    // Debug: list buttons with their text
    const tButtonScan = Date.now();
    const buttons = await page
      .$$eval("button", (els) =>
        els.map((b) => ({ text: b.innerText, id: b.id || null }))
      )
      .catch(() => []);
    log(
      "Found buttons (first 20) (scan took",
      Date.now() - tButtonScan + "ms):",
      JSON.stringify(buttons.slice(0, 20), null, 2)
    );

    // Try to open House Rules by clicking the relevant button if present
    const btn = await page.$x("//button[contains(., 'House Rules')]");
    if (btn && btn.length) {
      log("Found House Rules button, clicking");
      await btn[0].click();
      await sleep(500);
    } else {
      log(
        "House Rules button not found by text; attempting fallback selectors"
      );
      // fallback: try a few likely selectors
      const fallbacks = [
        ".Actions button",
        "button[aria-label='House Rules']",
        "button[data-testid='house-rules']",
      ];
      let clicked = false;
      for (const sel of fallbacks) {
        const tSelStart = Date.now();
        const el = await page.$(sel);
        log(
          "Checked selector",
          sel,
          " (took",
          Date.now() - tSelStart + "ms) ->",
          !!el
        );
        if (el) {
          log("Clicking fallback selector", sel);
          await el.click();
          clicked = true;
          await sleep(500);
          break;
        }
      }
      if (!clicked) console.log("No fallback selector matched");
    }

    // Wait for the HouseRules dialog to appear
    try {
      const tWaitStart = Date.now();
      await page.waitForSelector(".HouseRules", { timeout: 2000 });
      log(
        "HouseRules dialog is present (waited",
        Date.now() - tWaitStart + "ms)"
      );
    } catch (e) {
      log("HouseRules dialog did not appear");
      // Dump a small HTML snippet around where it might be
      try {
        const snippet = await page.$$eval("body *:nth-child(-n+60)", (els) =>
          els.map((e) => e.outerHTML).join("\n")
        );
        console.log(
          "Body snippet (first ~60 elements):\n",
          snippet.slice(0, 4000)
        );
      } catch (err) {
        log(
          "Could not capture snippet:",
          err && err.message ? err.message : err
        );
      }
    }

    // Evaluate whether switches are disabled and capture extra debug info
    const switches = await page
      .$$eval(".HouseRules .RuleSwitch", (els) =>
        els.map((e) => ({
          id: e.id || "",
          disabled: e.disabled,
          outer: e.outerHTML,
        }))
      )
      .catch(() => []);
    log("Switches found:", switches.length);
    switches.forEach((s, i) => {
      log(`${i}: id='${s.id}', disabled=${s.disabled}`);
      if (s.outer && s.outer.length > 0) {
        log(`  outerHTML (first 200 chars): ${s.outer.slice(0, 200)}`);
      }
    });

    // Also attempt to log the current game state and name visible in DOM if present
    try {
      const stateText = await page.$eval(".Table, body", (el) =>
        document.body.innerText.slice(0, 2000)
      );
      log(
        "Page text snippet:\n",
        stateText.split("\n").slice(0, 40).join("\n")
      );
    } catch (err) {
      log(
        "Could not extract page text snippet:",
        err && err.message ? err.message : err
      );
    }

    // Save a screenshot for inspection (workspace is mounted when running container)
  const out = path.join(OUT_DIR, 'house-rules.png');
    try {
      const tShot = Date.now();
      await page.screenshot({ path: out, fullPage: true });
      log("Screenshot saved to", out, "(took", Date.now() - tShot + "ms)");
    } catch (e) {
      log("Screenshot failed:", e && e.message ? e.message : e);
    }

  await browser.close();
  clearGlobalTimeout();
  log("Puppeteer test finished successfully");
  } catch (err) {
    log("Puppeteer test failed:", err && err.stack ? err.stack : err);
    if (browser)
      try {
        await browser.close();
      } catch (e) {}
    process.exit(2);
  }
})();
