/*
 * Playwright tests: IDAA Novi UUID Authentication
 *
 * WHAT THIS FILE TESTS
 * ─────────────────────────────────────────────────────────────────────────────
 * The IDAA module is embedded inside Novi member pages as an iframe.  When Novi
 * renders the host page it injects the current member's UUID into the iframe src:
 *
 *   src="/idaa/archives?uuid=<CustomerUniqueId>&iframe=true&key=..."
 *
 * The IDAA sub-layout (src/routes/idaa/(idaa)/+layout.svelte) reads that UUID,
 * calls the external Novi API to verify it is a real member, and then grants the
 * appropriate access level (anonymous → authenticated → trusted → administrator).
 *
 * WHY THESE TESTS EXIST
 * ─────────────────────────────────────────────────────────────────────────────
 * 1. PRIVACY — A prior AI agent accidentally made IDAA content public.  The auth
 *    gate test (test 1) is the regression guard.  It must always run first.
 *
 * 2. ROOT-CAUSE FIX COVERAGE — The Dexie cache fast-path can return a stale
 *    site_domain record whose cfg_json is missing `novi_idaa_api_key`.  When that
 *    happens verification fails silently and the user sees "Access Denied".  Two
 *    files were changed to fix this:
 *
 *      ae_core__site.ts           — after background refresh saves to Dexie, push
 *                                   fresh cfg_json into $ae_loc so store subscribers
 *                                   are notified.
 *      idaa/(idaa)/+layout.svelte — track $ae_loc.site_cfg_json outside untrack()
 *                                   so Effect 2 re-runs when cfg arrives; guard
 *                                   prevents double-verification if already done.
 *
 *    Test 4 specifically exercises this stale-cache → auto-retry path.
 *
 * MOCKING STRATEGY
 * ─────────────────────────────────────────────────────────────────────────────
 * • All /v3/ API calls are intercepted with a page.route glob (any-origin /v3/ any-path).
 *   Only site_domain/search needs a specific response; everything else returns [].
 *
 * • The external Novi API (https://www.idaa.org/api/customers/:uuid) is
 *   intercepted with a page.route glob (any-origin /api/customers/ any-path).
 *   This prevents any real network traffic to idaa.org during CI/dev runs.
 *
 * • localStorage is seeded with page.addInitScript() before each navigate so the
 *   persisted $ae_loc and $ae_idaa_loc stores start in a known state.
 *
 * ROUTE UNDER TEST
 * ─────────────────────────────────────────────────────────────────────────────
 * /idaa/archives  — simplest IDAA sub-route; auth gate fires at the shared
 *                   (idaa)/+layout.svelte before any child content loads, so
 *                   no archive fixture data is needed for the auth tests.
 */

import { test, expect } from '@playwright/test';
import { ae_app_local_data_defaults } from './_helpers/ae_defaults';
import { testing_account_id, mock_site_domain } from './_helpers/env';

// ─── Constants ────────────────────────────────────────────────────────────────

/**
 * Synthetic Novi UUID used as the test subject.
 * Must NOT be a real member UUID — nothing here reaches the real Novi API.
 * This UUID is also listed in the fresh_site_cfg_json novi_trusted_li so that
 * a verified member gets 'trusted' access (not just 'authenticated').
 */
const TEST_NOVI_UUID = 'c9ea07b5-06b0-4a43-a2d0-8d06558c8a82';

/**
 * Synthetic Novi API key stored in site_cfg_json.novi_idaa_api_key.
 * The layout uses this as a Basic auth header when calling the Novi API.
 * Any non-empty string works here — the mocked route never inspects it.
 */
const TEST_NOVI_API_KEY = 'Basic dGVzdC10ZXN0LXRlc3Q=';

/**
 * Minimal Novi API member payload.
 * Matches the shape that verify_novi_uuid() reads in (idaa)/+layout.svelte:
 *   FirstName, LastName → display name  ("IDAA T.")
 *   Email               → stored as idaa_loc.novi_email
 */
const mock_novi_member = {
    FirstName: 'IDAA',
    LastName: 'TestMember',
    Name: 'IDAA TestMember',
    Email: 'test+novi@oneskyit.com',
    CustomerUniqueId: TEST_NOVI_UUID
};

/** IDAA sub-route to navigate to.  All auth tests use this path. */
const IDAA_ROUTE = '/idaa/archives';

// ─── site_cfg_json factories ──────────────────────────────────────────────────

/**
 * Fresh (correct) site_cfg_json — what the API returns after a Novi API key
 * is properly configured.  This is the happy-path config.
 *
 * novi_trusted_li includes TEST_NOVI_UUID so verified members get 'trusted'
 * access, which satisfies the (idaa)/+layout.svelte gate condition.
 */
function fresh_site_cfg_json() {
    return {
        slct__event_id: null,
        novi_idaa_api_key: TEST_NOVI_API_KEY,
        novi_api_root_url: 'https://www.idaa.org/api',
        novi_admin_li: [],
        novi_trusted_li: [TEST_NOVI_UUID]
    };
}

/**
 * Stale (broken) site_cfg_json — what Dexie might have cached if the site
 * record was updated server-side after the user's last visit.  Specifically
 * missing novi_idaa_api_key, which causes verify_novi_uuid() to bail early.
 *
 * This is the cfg that triggers the bug covered by test 4.
 */
function stale_site_cfg_json() {
    return {
        slct__event_id: null
        // Intentionally omitted: novi_idaa_api_key, novi_api_root_url, novi_trusted_li
    };
}

// ─── Route mock helpers ───────────────────────────────────────────────────────

/**
 * Mock all /v3/ API traffic for IDAA auth tests.
 *
 * Only site_domain/search needs a real response — everything else (archive
 * lists, event lists, etc.) can safely return an empty array because the auth
 * gate fires before any child content renders.
 *
 * @param cfg_json  The cfg_json to embed in the site_domain response.
 *   Use fresh_site_cfg_json() for normal operation.
 *   Use stale_site_cfg_json() to simulate a stale Dexie cache.
 */
async function mock_v3_routes(page: any, cfg_json: any) {
    await page.route('**/v3/**', async (route: any) => {
        const url = route.request().url();

        // Bootstrap call: +layout.ts looks up the current domain to get account_id
        // and site_cfg_json.  This is where the stale-cache bug manifests.
        if (url.includes('site_domain/search')) {
            return route.fulfill({
                status: 200,
                contentType: 'application/json',
                body: JSON.stringify({
                    data: [{ ...mock_site_domain, cfg_json }]
                })
            });
        }

        // Everything else — archives, events, lookup tables — returns empty.
        // The auth gate fires before child content renders so these are never used.
        return route.fulfill({
            status: 200,
            contentType: 'application/json',
            body: JSON.stringify({ data: [] })
        });
    });
}

/**
 * Mock the external Novi API endpoint called by verify_novi_uuid().
 *
 * URL pattern: https://www.idaa.org/api/customers/<uuid>
 * Playwright intercepts cross-origin requests from the page context, so this
 * stops any real traffic from leaving the test environment.
 *
 * @param status  200 for a recognised member, 404 for an unknown UUID, or
 *                any other status to simulate server errors.
 * @param body    Response body.  For 200 pass mock_novi_member.  404 can be empty.
 */
async function mock_novi_api(page: any, status: number, body?: any) {
    await page.route('**/api/customers/**', async (route: any) => {
        return route.fulfill({
            status,
            contentType: 'application/json',
            body: body ? JSON.stringify(body) : '{}'
        });
    });
}

// ─── localStorage seed helpers ────────────────────────────────────────────────

/**
 * Seed both ae_loc and ae_idaa_loc into localStorage before page load.
 *
 * Must be called via page.addInitScript() which runs before any page JS so that
 * store_versions.ts sees the correct __version and leaves the stores intact.
 *
 * ae_loc is seeded as fully anonymous (no auth flags set) — the IDAA layout must
 * earn access via Novi verification, not inherit it from a cached session.
 *
 * ae_idaa_loc is seeded as a clean slate (no cached UUID) so each test starts
 * fresh and does not inherit a verified state from a previous test run.
 *
 * @param ae_loc_overrides  Merged on top of the defaults.  Use this to set
 *   iframe:true, or to inject a stale site_cfg_json for the stale-cache test.
 */
async function seed_anonymous_session(page: any, ae_loc_overrides: any = {}) {
    await page.addInitScript(
        ({ defaults, account_id, overrides }: any) => {
            // Fully anonymous ae_loc — no auth flags
            const ae_loc_data = {
                ...defaults,
                account_id,
                allow_access: true,
                access_type: 'anonymous',
                authenticated_access: false,
                trusted_access: false,
                administrator_access: false,
                ...overrides
            };
            window.localStorage.setItem('ae_loc', JSON.stringify(ae_loc_data));

            // Clean ae_idaa_loc — no cached UUID from previous sessions.
            // IMPORTANT: must include all nested objects (bb, archives, recovery_meetings)
            // that the IDAA layout writes to after verification. If bb is missing,
            // verify_novi_uuid() throws "Cannot set properties of undefined" when it
            // tries to reset bb.qry__hidden — caught by the try/catch which then nulls
            // novi_uuid, making the UUID span invisible even when access is granted.
            const ae_idaa_loc_data = {
                ver: '2024-08-21_1646',
                novi_uuid: null,
                novi_email: null,
                novi_full_name: null,
                novi_verified: false,
                novi_admin_li: [],
                novi_trusted_li: [],
                novi_jitsi_mod_li: [],
                ds: {},
                idaa_cfg_json: {},
                // bb is required — layout writes to bb.qry__hidden and bb.qry__enabled
                // at the end of a successful verification pass.
                bb: {
                    enabled: 'enabled',
                    hidden: 'not_hidden',
                    limit: 50,
                    offset: 0,
                    edit_kv: {},
                    edit__post_obj: null,
                    edit__post_comment_obj: null,
                    show_list__post_obj_li: true,
                    qry__enabled: 'enabled',
                    qry__hidden: 'not_hidden',
                    qry__limit: 25,
                    qry__offset: 0,
                    qry__order_by: 'updated_on',
                    qry__order_by_li: { updated_on: 'DESC', created_on: 'DESC' }
                },
                archives: {
                    enabled: 'enabled',
                    hidden: 'not_hidden',
                    limit: 150,
                    offset: 0,
                    edit_kv: {},
                    edit__archive_obj: null,
                    edit__archive_content_obj: null
                }
            };
            window.localStorage.setItem(
                'ae_idaa_loc',
                JSON.stringify(ae_idaa_loc_data)
            );

            // Suppress outbound email calls during tests (checked in api.ts)
            (window as any).__ae_test_mode = true;
        },
        {
            defaults: ae_app_local_data_defaults,
            account_id: testing_account_id,
            overrides: ae_loc_overrides
        }
    );
}

// ─── Tests ────────────────────────────────────────────────────────────────────

test.describe('IDAA Novi UUID authentication', () => {
    // Pipe browser-side JS errors to test stdout so failures are diagnosable
    // without opening Playwright's trace viewer.
    test.beforeEach(async ({ page }) => {
        page.on('pageerror', (err) =>
            console.error(`BROWSER ERROR: ${err.message}`)
        );
    });

    // ── Test 1: Auth gate ─────────────────────────────────────────────────────
    //
    // THIS TEST MUST RUN FIRST AND MUST ALWAYS PASS.
    //
    // IDAA = International Doctors in Alcoholics Anonymous.  All content is
    // strictly private.  A prior AI agent accidentally made IDAA BB public —
    // this test is the regression guard for the access gate.
    //
    // If this test fails it means unauthenticated visitors can see IDAA content.
    // That is a Sev-1 privacy failure regardless of any other test results.

    test('auth gate: no UUID and no auth → Access Denied is shown', async ({
        page
    }) => {
        // Stale cfg is fine here — the point is that no UUID means no verification
        await mock_v3_routes(page, stale_site_cfg_json());
        await seed_anonymous_session(page);

        // Navigate WITHOUT a uuid param — simulates a direct URL visit, a search
        // engine crawler, or a Novi member who hasn't been given the iframe URL
        await page.goto(IDAA_ROUTE);

        // The auth gate in (idaa)/+layout.svelte must block this request.
        // "Access Denied" heading is the canonical signal that the gate fired.
        await expect(
            page.getByRole('heading', { name: 'Access Denied' })
        ).toBeVisible({ timeout: 5000 });

        // Confirm no private content leaked through — "IDAA Novi UUID not found!"
        // is shown inside the denied block when uuid verification was not attempted.
        // This proves the gate rendered correctly, not just that something went wrong.
        await expect(page.getByText('IDAA Novi UUID not found!')).toBeVisible();
    });

    // ── Test 2: Happy path ────────────────────────────────────────────────────
    //
    // Fresh site_cfg_json has novi_idaa_api_key → verify_novi_uuid() runs.
    // Novi API returns a valid member → access is granted.
    // This is the normal day-to-day flow for Novi members visiting IDAA pages.

    test('happy path: valid UUID + fresh cfg → access granted', async ({
        page
    }) => {
        // Fresh cfg has novi_idaa_api_key so the layout can call the Novi API
        await mock_v3_routes(page, fresh_site_cfg_json());

        // Novi confirms this UUID is a real member
        await mock_novi_api(page, 200, mock_novi_member);

        // Start anonymous — access must come entirely from Novi verification
        await seed_anonymous_session(page);

        // Navigate with UUID param — same URL shape as the Novi iframe template
        await page.goto(`${IDAA_ROUTE}?uuid=${TEST_NOVI_UUID}`);

        // "Access Denied" must not appear after successful verification.
        // Timeout is generous to allow the async Novi API round-trip to complete.
        await expect(
            page.getByRole('heading', { name: 'Access Denied' })
        ).not.toBeVisible({ timeout: 6000 });

        // The layout renders $idaa_loc.novi_uuid in a footer span after auth.
        // Its presence confirms the full verification pipeline ran and the store
        // was written — not just that the gate condition happened to pass.
        // UUID appears inside a longer span ("Novi: [icon] [uuid] [name] [email]"),
        // so exact:false is required — getByText default is whole-text match.
        await expect(page.getByText(TEST_NOVI_UUID, { exact: false })).toBeVisible({
            timeout: 6000
        });
    });

    // ── Test 3: Invalid UUID ──────────────────────────────────────────────────
    //
    // UUID is present in the URL but the Novi API does not recognise it
    // (e.g. expired membership, wrong UUID, typo in the Novi template).
    // The user must be denied access — IDAA is not publicly readable.

    test('invalid UUID: Novi API 404 → Access Denied with UUID not found', async ({
        page
    }) => {
        await mock_v3_routes(page, fresh_site_cfg_json());

        // Novi API does not recognise this UUID
        await mock_novi_api(page, 404);

        await seed_anonymous_session(page);
        await page.goto(`${IDAA_ROUTE}?uuid=${TEST_NOVI_UUID}`);

        await expect(
            page.getByRole('heading', { name: 'Access Denied' })
        ).toBeVisible({ timeout: 5000 });

        // The specific "UUID not found!" message confirms the failure is the
        // post-verification denial path, not the no-uuid path from test 1.
        await expect(page.getByText('IDAA Novi UUID not found!')).toBeVisible();
    });

    // ── Test 4: Stale cache → reactive retry ─────────────────────────────────
    //
    // THIS TEST COVERS THE SPECIFIC BUG FIXED ON 2026-03-25.
    //
    // Scenario: $ae_loc.site_cfg_json is stale (missing novi_idaa_api_key —
    // e.g. key was added server-side after the user's last visit, and their
    // persisted store still has the old cfg). Effect 2 in the IDAA layout
    // reads site_cfg_json → key is null → verify_novi_uuid() bails → Access Denied.
    //
    // The fix: Effect 2 now tracks $ae_loc.site_cfg_json OUTSIDE untrack(), so
    // when the store is updated later (by any code path — background refresh,
    // admin panel, etc.) the effect re-runs automatically and retries verification.
    //
    // This test proves the reactive-tracking half of the fix by simulating the
    // store update directly via a StorageEvent, which is what svelte-persisted-store
    // uses internally when another code path writes to $ae_loc.  The test:
    //
    //   1. Seeds localStorage with stale cfg (no api_key)
    //   2. Navigates → Access Denied (verify fails immediately)
    //   3. Dispatches a StorageEvent to push fresh cfg into $ae_loc
    //   4. Effect 2 re-runs (reactive tracking), retries with real api_key
    //   5. Novi API returns 200 → access granted — no reload needed
    //
    // WHY StorageEvent instead of pre-seeding Dexie:
    //   In the test environment, Dexie is empty on first load, so lookup_site_domain
    //   takes the slow path (single API call, no background refresh). Pre-seeding
    //   Dexie would require an extra navigation-and-reload cycle and tightly couples
    //   this test to the ae_core__site.ts background-refresh plumbing. The StorageEvent
    //   approach tests the reactive tracking fix in isolation — cleaner and faster.

    test('stale cache: $ae_loc.site_cfg_json update triggers Effect 2 retry → access granted without reload', async ({
        page
    }) => {
        // Site domain always returns stale cfg for this test — the update comes via
        // StorageEvent below, not from a background refresh.
        await mock_v3_routes(page, stale_site_cfg_json());

        // Novi API is primed and ready — it only gets called AFTER Effect 2 re-runs
        // with the fresh api_key (step 4 above). If verification never retries this
        // mock is never invoked and the test fails at the access-granted assertion.
        await mock_novi_api(page, 200, mock_novi_member);

        // Seed localStorage with stale site_cfg_json.
        // This replicates a real user's persisted store after a server-side config change.
        await seed_anonymous_session(page, {
            site_cfg_json: stale_site_cfg_json()
        });

        await page.goto(`${IDAA_ROUTE}?uuid=${TEST_NOVI_UUID}`);

        // Phase 1 — stale cfg causes immediate verification failure.
        // verify_novi_uuid() exits early (no api_key) and sets novi_verified=false.
        await expect(
            page.getByRole('heading', { name: 'Access Denied' })
        ).toBeVisible({ timeout: 5000 });

        // Phase 2 — simulate $ae_loc.site_cfg_json being updated externally.
        //
        // svelte-persisted-store subscribes to window 'storage' events so that
        // writes from other tabs/contexts (e.g. ae_core__site.ts after a background
        // refresh) propagate into the in-memory store. Dispatching the event
        // manually here has the same effect: the store updates in-memory, Svelte
        // marks Effect 2 dirty (site_cfg_json was a tracked dependency), and the
        // effect re-runs on the next microtask.
        await page.evaluate(
            ({ fresh_cfg }: { fresh_cfg: any }) => {
                const raw = window.localStorage.getItem('ae_loc');
                const current = raw ? JSON.parse(raw) : {};
                const updated = { ...current, site_cfg_json: fresh_cfg };
                const newValue = JSON.stringify(updated);
                window.localStorage.setItem('ae_loc', newValue);
                // Dispatch synthetic storage event — triggers svelte-persisted-store
                // listener in the same tab (native 'storage' event only fires in
                // OTHER tabs; manual dispatch reaches same-tab listeners).
                window.dispatchEvent(
                    new StorageEvent('storage', {
                        key: 'ae_loc',
                        newValue,
                        storageArea: window.localStorage
                    })
                );
            },
            { fresh_cfg: fresh_site_cfg_json() }
        );

        // Phase 3 — Effect 2 re-runs with fresh cfg, calls Novi API, grants access.
        // Access Denied must disappear WITHOUT a page reload.
        await expect(
            page.getByRole('heading', { name: 'Access Denied' })
        ).not.toBeVisible({ timeout: 8000 });

        // UUID in footer span confirms full verification pipeline completed and
        // $idaa_loc.novi_uuid was written — not just that the gate check passed.
        await expect(page.getByText(TEST_NOVI_UUID, { exact: false })).toBeVisible({
            timeout: 8000
        });
    });

    // ── Test 5: Reload button in iframe Access Denied ─────────────────────────
    //
    // The Reload / Retry button is a UX stopgap for the stale-cache race.
    // Even after the root-cause fix, edge cases may still show Access Denied
    // (e.g. Novi API is slow or temporarily down). The button lets the user
    // self-recover without involving the Novi site operators.
    //
    // The button is intentionally ONLY shown in iframe mode — outside an iframe
    // the regular browser reload button is accessible and the extra UI is noise.

    test('iframe mode: Reload/Retry button is visible on Access Denied', async ({
        page
    }) => {
        // Force Access Denied by: stale cfg (no api_key) + Novi 404 (invalid UUID)
        // This guarantees we stay on the denied screen for the assertion.
        await mock_v3_routes(page, stale_site_cfg_json());
        await mock_novi_api(page, 404);

        // Seed with iframe:true — same as what the root layout sets when
        // ?iframe=true is in the URL.  We seed it directly to avoid relying
        // on the URL param being processed before the layout mounts.
        await seed_anonymous_session(page, { iframe: true });

        // Navigate with iframe=true and a UUID — same URL as the Novi embed
        await page.goto(
            `${IDAA_ROUTE}?uuid=${TEST_NOVI_UUID}&iframe=true`
        );

        await expect(
            page.getByRole('heading', { name: 'Access Denied' })
        ).toBeVisible({ timeout: 5000 });

        // Reload / Retry button must be present (added in fix commit 2026-03-25)
        await expect(
            page.getByRole('button', { name: /Reload \/ Retry/i })
        ).toBeVisible();

        // Sanity-check: button is scoped to iframe context — the #ae_idaa div
        // gets class="iframe" when $ae_loc.iframe is true (idaa/+layout.svelte).
        // If this fails the iframe flag was not respected by the layout.
        await expect(page.locator('#ae_idaa.iframe')).toBeVisible();
    });
});
