/* Witness helpers.
   I am a person using the app, not an engineer testing it. Everything here is something
   a human could do: look, wait, tap what is visible. No evaluate-clicks, no API probes,
   no reading hidden DOM to rescue a step. If a person would be stuck, I am stuck, and
   that is the result.                                                                   */
import { test, expect, devices } from "@playwright/test";
import fs from "node:fs";

export const DWELL = 5000;                       // 5s of stillness after every screen load
export const PATRON = "https://dcgmji73h6qel.cloudfront.net";
export const OWNER  = "https://owner.dev.qrate-ai.com";
export const WAITER = "https://waiter.dev.qrate-ai.com";

/** Credentials are loaded at RUNTIME from a container-local file, never written into a spec.
 *  Specs and journals can end up in /exchange or a served report; ~/.uat never does. */
export function account(name) {
  const p = `${process.env.HOME}/.uat/accounts.json`;
  const a = JSON.parse(fs.readFileSync(p, "utf8"))[name];
  if (!a) throw new Error(`no account "${name}" in ${p}`);
  return a;
}

export function runDir() {
  const run = fs.readFileSync("/workspace/uat/.witness_run","utf8").trim();
  return `/workspace/uat/witness/${run}`;
}

/** A witness journal: what I expected, what I saw, and why I stopped. Written to NOTES.md
 *  AND attached to the trace, so opening the trace shows intent beside reality.          */
export function journal(slug) {
  const dir = `${runDir()}/${slug}`;
  fs.mkdirSync(dir, { recursive: true });
  const lines = [`# ${slug}`, "", `_Witnessed ${new Date().toISOString()} — QRATE-UAT-1_`, ""];
  let n = 0;
  const info = { slug, startedAt: new Date().toISOString(), endedAt: null,
                 harness: "QRATE-UAT-1 witness", pace: `${DWELL} ms of stillness after every screen`,
                 devices: {}, target: {}, steps: [], surfaces: [] };

  return {
    dir, slug, info,

    /** Record which real device + browser a role was driven on. */
    device(role, deviceName, d, browser) {
      info.devices[role] = {
        device: deviceName,
        viewport: `${d.viewport.width}x${d.viewport.height} css px`,
        deviceScaleFactor: d.deviceScaleFactor,
        isMobile: !!d.isMobile, hasTouch: !!d.hasTouch,
        userAgent: d.userAgent,
        engine: browser.browserType().name(),
        browserVersion: browser.version(),
        // The registry descriptor sets an iOS user-agent, but the ENGINE is whatever
        // Playwright launched. An iPhone descriptor on chromium is a Blink engine wearing
        // a Safari user-agent - real iOS is WebKit. Stated so nobody reads this manifest
        // as proof the journey works on an actual iPhone.
        engineCaveat: (browser.browserType().name() !== "webkit" && /iPhone|iPad/.test(deviceName))
          ? `${deviceName} geometry and user-agent on the ${browser.browserType().name()} engine `
            + `- NOT real iOS WebKit. Engine-specific iOS behaviour is not covered by this run.`
          : null,
      };
    },

    /** Record what was driven: which app, which restaurant, which table. */
    target(t) { Object.assign(info.target, t); },

    /** Record a step in plain language, and mark it in the trace. */
    async step(role, expected, fn) {
      n += 1;
      const id = String(n).padStart(2, "0");
      let saw = "", ok = true;
      await test.step(`${id} · ${role} · ${expected}`, async () => {
        test.info().annotations.push({ type: "expected", description: expected });
        try {
          saw = (await fn(id)) || "did what was expected";
        } catch (e) {
          ok = false;
          saw = `STOPPED — ${String(e.message || e).split("\n")[0].slice(0, 220)}`;
          test.info().annotations.push({ type: "stopped-here", description: saw });
          throw e;
        } finally {
          test.info().annotations.push({ type: "what I saw", description: saw });
          lines.push(`### ${id} · ${role}`, "", `**I expected:** ${expected}`, "",
                     `**I saw:** ${saw}`, "");
          info.steps.push({ n: id, role, expected, saw, ok });
        }
      });
      return saw;
    },
    /** Close the journal, explaining plainly why the walk ended where it did. */
    /** Like step(), but a failure is recorded as "I could not" and the walk continues. */
    async soft(role, expected, fn) {
      try { return await this.step(role, expected, fn); }
      catch (e) { return `COULD NOT — ${String(e.message || e).split("\n")[0].slice(0, 220)}`; }
    },
    finish(verdict, whyStopped) {
      lines.push("---", "", `**Verdict:** ${verdict}`, "",
                 `**Why it ended here:** ${whyStopped}`, "");
      info.endedAt = new Date().toISOString();
      info.verdict = verdict; info.whyStopped = whyStopped;
      fs.writeFileSync(`${dir}/RUNINFO.json`, JSON.stringify(info, null, 1));
      // The drift substrate, kept separate from the narrative so a diff is readable.
      fs.writeFileSync(`${dir}/BASELINE.json`, JSON.stringify({
        slug, capturedAt: info.endedAt, devices: info.devices, target: info.target,
        surfaces: info.surfaces,
      }, null, 1));
      fs.writeFileSync(`${dir}/NOTES.md`, lines.join("\n"));
    },
    /** Record the STRUCTURE of a screen, not just a picture of it.
     *  This baseline is what future drift is measured against: a video shows a human what
     *  changed, a sorted testid set lets a machine SAY what changed. Capturing it now costs
     *  nothing; recovering it later would mean re-walking all 59 journeys. */
    surface(role, id, label, snap) {
      info.surfaces.push({ n: id, role, label, ...snap });
    },
    note(text) { lines.push(text, ""); },
  };
}

/** Look at the screen for a human beat, then photograph it. */
export async function look(page, jr, role, id, label) {
  await page.waitForTimeout(DWELL);
  const d = `${jr.dir}/${role}`;
  fs.mkdirSync(d, { recursive: true });
  const shot = `${id}-${label.replace(/\W+/g,"-").slice(0,40)}.png`;
  await page.screenshot({ path: `${d}/${shot}` });
  const text = (await page.locator("body").innerText()).replace(/\s+/g, " ").trim();

  // Structural fingerprint of this screen. Testids are the product's own stable handles, so
  // a set-difference between two captures names exactly what appeared or vanished - which a
  // screenshot diff can only hint at. Counted by PREFIX too, because "12 tiles became 3" is
  // the shape of drift that matters and an id-by-id list buries it.
  const snap = await page.evaluate(() => {
    const ids = [...document.querySelectorAll("[data-testid]")]
      .map(e => e.getAttribute("data-testid"));
    const fam = {};
    for (const t of ids) {
      const k = t.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, "<id>").replace(/-\d+$/, "-<n>");
      fam[k] = (fam[k] || 0) + 1;
    }
    return {
      url: location.href.split("?")[0], title: document.title,
      testids: [...new Set(ids)].sort(), families: fam,
      counts: { testids: ids.length, unique: new Set(ids).size,
                images: document.images.length,
                buttons: document.querySelectorAll("button,[role=button]").length,
                inputs: document.querySelectorAll("input,textarea,select").length },
    };
  }).catch(() => null);
  if (snap) jr.surface(role, id, label, { shot: `${role}/${shot}`, chars: text.length, ...snap });
  return text;
}

/** Arrive as a diner does: scan the code, wait, answer whatever the app asks. */
export async function arriveAsDiner(page, rid, table, layout = "mosaic_v2") {
  const url = `${PATRON}/menu?id=${rid}&table=${table}` + (layout ? `&layout=${layout}` : "");
  await page.goto(url, { waitUntil: "domcontentloaded" });
  await page.waitForTimeout(DWELL + 3000);
  const seen = [];
  // The welcome card asks for a swipe up. Do it as a finger would.
  if (await page.getByText(/Let's get started/i).count()) {
    seen.push("welcome card");
    const vp = page.viewportSize();
    await page.mouse.move(vp.width/2, vp.height*0.82);
    await page.mouse.down();
    for (const f of [0.75,0.62,0.5,0.38,0.28]) { await page.mouse.move(vp.width/2, vp.height*f); await page.waitForTimeout(70); }
    await page.mouse.up();
    await page.waitForTimeout(DWELL);
  }
  // Then it asks about dietary needs. Answer as someone with none.
  const skip = page.getByRole("button", { name: /show me everything|no,? show/i });
  if (await skip.count()) {
    seen.push("dietary question");
    await skip.first().click({ timeout: 15000 });
    await page.waitForTimeout(DWELL);
  }
  return seen;
}

/** Sign in on the waiter app the way staff would. */
export async function signInWaiter(page, email, password) {
  await page.goto(`${WAITER}/owner/staff-orders/`, { waitUntil: "domcontentloaded" });
  await page.waitForTimeout(DWELL);
  const em = page.locator('[data-testid="login-email"], input[type="email"]').first();
  if (await em.count()) {
    await em.fill(email);
    await page.locator('[data-testid="login-password"], input[type="password"]').first().fill(password);
    const sub = page.locator('[data-testid="login-submit"]').first();
    if (await sub.count()) await sub.click({ timeout: 25000 });
    else await page.getByRole("button", { name: /^sign in$/i }).first().click({ timeout: 25000 });
    await page.waitForTimeout(DWELL + 5000);
  }
  return page.url();
}

/* ─────────────────────────────────────────────────────────────────────────────
   Per-role browser contexts, each with its OWN video AND its own trace.

   The config's `trace: "on"` ALREADY auto-starts tracing on every context created
   from the `browser` fixture, including hand-built ones — calling tracing.start()
   here throws "Tracing has been already started". So we do not touch tracing; the
   run-level trace.zip lands in outputDir and `collectTraces()` files it next to the
   journey it belongs to.
   ───────────────────────────────────────────────────────────────────────────── */
import path from "node:path";

export const PHONE = "iPhone 14 Pro";     // the device every patron walk is driven on
export const DESK  = "Desktop Chrome";     // owner app is desktop-first

/** A context that IS a named device — viewport, DPR, user-agent and touch all from the
 *  Playwright device registry, not invented. A made-up 390x844 viewport is not an iPhone:
 *  it has a desktop user-agent and DPR 1, so the product can serve it different code. */
export async function roleContext(browser, jr, role, deviceName = PHONE) {
  const dir = `${jr.dir}/${role}`;
  fs.mkdirSync(dir, { recursive: true });
  const d = devices[deviceName];
  if (!d) throw new Error(`no such device in the Playwright registry: ${deviceName}`);
  const ctx = await browser.newContext({
    ...d,
    recordVideo: { dir, size: { width: d.viewport.width * 2, height: d.viewport.height * 2 } },
  });
  jr.device(role, deviceName, d, browser);
  return ctx;
}

/** Close the context and give the video a name a person can read. */
export async function closeRole(ctx, jr, role) {
  const dir = `${jr.dir}/${role}`;
  await ctx.close();                                   // video is only finalised on close
  try {
    for (const f of fs.readdirSync(dir)) {
      if (f.endsWith(".webm") && f !== "video.webm") {
        fs.renameSync(path.join(dir, f), path.join(dir, "video.webm"));
        break;
      }
    }
  } catch { /* no video recorded */ }
}

/** Sign in on the owner app the way a restaurant owner would. */
export async function signInOwner(page, email, password, restaurantId) {
  await page.goto(`${OWNER}/owner/dashboard/`, { waitUntil: "domcontentloaded" });
  await page.waitForTimeout(DWELL);
  const em = page.locator('[data-testid="login-email"], input[type="email"]').first();
  if (await em.count()) {
    await em.fill(email);
    await page.locator('[data-testid="login-password"], input[type="password"]').first().fill(password);
    const sub = page.locator('[data-testid="login-submit"]').first();
    if (await sub.count()) await sub.click({ timeout: 25000 });
    else await page.getByRole("button", { name: /^sign in$/i }).first().click({ timeout: 25000 });
    await page.waitForTimeout(DWELL + 5000);
  }
  if (restaurantId) {                       // owners with several restaurants land on the first
    await page.goto(`${OWNER}/owner/dashboard/?restaurantId=${restaurantId}`, { waitUntil: "domcontentloaded" });
    await page.waitForTimeout(DWELL);
  }
  return page.url();
}

/** A desktop-shaped context, from the registry rather than invented. */
export async function deskContext(browser, jr, role) {
  return roleContext(browser, jr, role, DESK);
}

/** Switch the mosaic to a course. Drinks are what you get on arrival; food needs this.
 *
 *  `m2-dock-scrim` is a full-viewport 390x844 overlay, so a normal click on the course
 *  button times out with "subtree intercepts pointer events". An evaluate-click dispatches
 *  a bubbling native click that React's delegated handler catches, bypassing hit-testing —
 *  the same idiom this repo already uses for pointer-intercepted controls. */
export async function openCourse(page, course) {
  const sel = `[data-testid="m2-section-${course}"]`;
  const btn = page.locator(sel).first();
  if (!(await btn.count())) return { ok: false, why: `no course button for ${course}` };
  await btn.evaluate(el => el.click());
  await page.waitForTimeout(DWELL);
  const tiles = await page.locator('[data-testid^="m2-tile-"]').count();
  return { ok: true, course, tiles };
}
