// UI test for the unmount-flush behaviour of /my-orders.
//
// my-orders.tsx schedules each delete behind a 7-second setTimeout so that
// the user has time to click "Undo". On unmount it MUST clear those timers
// AND immediately fire performDelete() for whatever IDs were still pending,
// otherwise navigating away during the undo window would silently lose the
// delete (the timer is GC'd with the page and the API call never goes out).
//
// Today the existing specs only cover:
//   - Undo aborts the timer (order-undo-toast.spec.mjs)
//   - Letting the timer expire (order-clear-finished-undo.spec.mjs)
// Neither covers the unmount-flush path, so this spec fills that gap.
//
// Flow exercised:
//   - Seed a completed order directly in the DB.
//   - Log in, go to /my-orders, click the trash icon + confirm.
//   - WITHOUT clicking Undo and well before the 7s timer fires, navigate
//     away from /my-orders by clicking the in-page Back button. This is an
//     in-SPA wouter navigation (setLocation("/services")), so the page
//     actually unmounts and the cleanup useEffect runs — unlike a full
//     page.goto() which would tear down the document and abort fetch.
//   - Verify a DELETE /api/orders/:id request was observed during the
//     navigation.
//   - Verify the order row is actually gone from the database.
//
// Run with:
//   DATABASE_URL=... pnpm --filter @workspace/tx-os test:e2e
//
// Browser binaries can be installed once with:
//   pnpm --filter @workspace/tx-os exec playwright install chromium

import { test, expect } from "@playwright/test";
import pg from "pg";

const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
  throw new Error(
    "DATABASE_URL must be set to run the unmount-flush UI test",
  );
}

// Same precomputed bcrypt hash used by the other UI specs ("TestPass123!").
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
  "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";

// UNDO_WINDOW_MS in artifacts/tx-os/src/pages/my-orders.tsx
const UNDO_WINDOW_MS = 7000;

const pool = new pg.Pool({ connectionString: DATABASE_URL });

const createdUserIds = [];
const createdOrderIds = [];

function uniqueSuffix() {
  return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

async function createUser(prefix, preferredLanguage = "en") {
  const username = `${prefix}_${uniqueSuffix()}`;
  const { rows } = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Unmount Flush UI', $4, true) RETURNING id`,
    [
      username,
      `${username}@example.com`,
      TEST_PASSWORD_HASH,
      preferredLanguage,
    ],
  );
  const id = rows[0].id;
  createdUserIds.push(id);
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id)
       SELECT $1, id FROM roles WHERE name = 'user'`,
    [id],
  );
  return { id, username };
}

async function getAvailableService() {
  const { rows } = await pool.query(
    `SELECT id,
            name_en AS "nameEn",
            name_ar AS "nameAr"
       FROM services
      WHERE is_available = true
      ORDER BY id ASC
      LIMIT 1`,
  );
  if (rows.length === 0) {
    throw new Error("No available service found in DB to test ordering against");
  }
  return rows[0];
}

async function insertOrder(userId, serviceId, status) {
  const { rows } = await pool.query(
    `INSERT INTO service_orders (user_id, service_id, status)
     VALUES ($1, $2, $3) RETURNING id`,
    [userId, serviceId, status],
  );
  const id = rows[0].id;
  createdOrderIds.push(id);
  return id;
}

async function orderExists(id) {
  const { rows } = await pool.query(
    `SELECT 1 FROM service_orders WHERE id = $1`,
    [id],
  );
  return rows.length > 0;
}

test.afterAll(async () => {
  if (createdOrderIds.length > 0) {
    await pool.query(`DELETE FROM service_orders WHERE id = ANY($1::int[])`, [
      createdOrderIds,
    ]);
  }
  if (createdUserIds.length > 0) {
    await pool.query(
      `DELETE FROM service_orders
        WHERE user_id = ANY($1::int[]) OR assigned_to = ANY($1::int[])`,
      [createdUserIds],
    );
    await pool.query(
      `DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
      [createdUserIds],
    );
    await pool.query(
      `DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
      [createdUserIds],
    );
    await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
      createdUserIds,
    ]);
  }
  await pool.end();
});

async function setLanguage(page, lang) {
  // i18n.ts reads `localStorage.getItem("tx-lang")` at module-init time, so
  // seed it before any page script runs.
  await page.addInitScript((l) => {
    try {
      window.localStorage.setItem("tx-lang", l);
    } catch {
      /* localStorage may not be available before navigation; safe to ignore */
    }
  }, lang);
}

async function loginViaUi(page, username, password) {
  await page.goto("/login");
  await page.locator("#username").fill(username);
  await page.locator("#password").fill(password);
  await Promise.all([
    page.waitForURL((url) => !url.pathname.endsWith("/login"), {
      timeout: 15_000,
    }),
    page.locator('form button[type="submit"]').click(),
  ]);
}

// Locate the live toast root. The shadcn Toaster renders Radix's
// <ToastViewport> as an <ol> containing one <li data-state="open"> per
// active toast. We pick the most recently opened one.
function toastRegion(page) {
  return page.locator('li[data-state="open"]').last();
}

test.describe("My Orders unmount flushes pending deletes", () => {
  test("English: navigating away during the undo window flushes the DELETE", async ({
    page,
  }) => {
    const user = await createUser("unmount_flush_en", "en");
    const service = await getAvailableService();
    const serviceName = service.nameEn;

    // Seed a completed order so the trash button is available immediately.
    const orderId = await insertOrder(user.id, service.id, "completed");

    await setLanguage(page, "en");
    await loginViaUi(page, user.username, TEST_PASSWORD);
    await page.goto("/my-orders");

    const card = page.locator(".glass-panel.rounded-xl").first();
    await expect(card).toContainText(serviceName);
    await expect(card).toContainText("Completed");

    // Track every DELETE /api/orders/:id request along with when it fired.
    // The unmount cleanup must flush our queued id immediately on unmount,
    // not after the 7s timer would have naturally elapsed.
    const deleteRequests = [];
    page.on("request", (req) => {
      const path = new URL(req.url()).pathname;
      if (
        path === `/api/orders/${orderId}` &&
        req.method() === "DELETE"
      ) {
        deleteRequests.push({ path, at: Date.now() });
      }
    });

    // Click trash + confirm "Yes, delete" to queue the deletion.
    await card.getByRole("button", { name: "Delete", exact: true }).click();
    const confirmDialog = page.getByRole("alertdialog");
    await expect(confirmDialog).toBeVisible();
    await confirmDialog
      .getByRole("button", { name: "Yes, delete", exact: true })
      .click();

    // Card disappears from the list while the delete is pending.
    await expect(
      page.locator(".glass-panel.rounded-xl").filter({ hasText: serviceName }),
    ).toHaveCount(0);

    // The Undo toast should be open. We deliberately do NOT click Undo —
    // we want the cleanup path, not the cancel path.
    const toast = toastRegion(page);
    await expect(toast).toContainText("Order deleted");
    await expect(toast.getByRole("button", { name: "Undo" })).toBeVisible();

    // Sanity: nothing should have fired yet — the 7s timer is still pending.
    expect(deleteRequests).toHaveLength(0);
    expect(await orderExists(orderId)).toBe(true);

    // Navigate away via the in-page Back button. This is wouter
    // setLocation("/services"), which is a real in-SPA navigation, so
    // MyOrdersPage actually unmounts and the cleanup useEffect runs. A
    // page.goto() to a new URL would instead tear down the document and
    // would not exercise the React unmount path.
    //
    // The waitForResponse timeout is intentionally well under
    // UNDO_WINDOW_MS so that a regression which removes the cleanup —
    // and therefore relies on the natural 7s setTimeout to fire — would
    // time out here instead of silently passing.
    const FLUSH_DEADLINE_MS = 3000;
    const deleteResponsePromise = page.waitForResponse(
      (r) =>
        new URL(r.url()).pathname === `/api/orders/${orderId}` &&
        r.request().method() === "DELETE",
      { timeout: FLUSH_DEADLINE_MS },
    );
    const navStartedAt = Date.now();
    await page.getByRole("button", { name: "Back", exact: true }).click();

    // We should land on /services thanks to the in-SPA navigation.
    await page.waitForURL((url) => url.pathname.endsWith("/services"), {
      timeout: 5_000,
    });

    // The cleanup must have flushed the pending delete: a DELETE request
    // for our order id was observed and it returned a 2xx.
    const deleteResp = await deleteResponsePromise;
    expect(deleteResp.status()).toBeGreaterThanOrEqual(200);
    expect(deleteResp.status()).toBeLessThan(300);
    expect(deleteRequests.map((r) => r.path)).toContain(
      `/api/orders/${orderId}`,
    );

    // The DELETE must have fired promptly after the unmount, not after
    // the natural 7s timer. Anything close to UNDO_WINDOW_MS would mean
    // the cleanup didn't actually flush.
    const firedAt = deleteRequests[0].at;
    expect(firedAt - navStartedAt).toBeLessThan(FLUSH_DEADLINE_MS);

    // And the row is actually gone from the database.
    expect(await orderExists(orderId)).toBe(false);

    // Forget about this id so afterAll doesn't try to delete a missing row.
    const idx = createdOrderIds.indexOf(orderId);
    if (idx !== -1) createdOrderIds.splice(idx, 1);
  });
});
