// Marketplace — customer-journey regression gate.
//
// CODIFIES the 17-step storefront → signup → provisioning → console flow
// that previously was only walked manually by a fix-author agent
// (PR #1635 — docs/SESSION-2026-05-17-CONVERGENCE.md).
//
// 17 steps:
//   01 storefront loads (/)
//   02 catalog renders products (>=5 in /apps grid)
//   03 product detail page loads (/app?slug=wordpress)
//   04 voucher input visible on /redeem
//   05 voucher redeem-preview shows credit
//   06 signup email input + button (/checkout sign-in surface)
//   07 subdomain picker shows omani.homes pool (/addons)
//   08 terms / continuation gate accept (cart-CTA on /apps becomes active)
//   09 PIN field appears after Send-code submit
//   10 (mocked) PIN verify → session
//   11 Org provisioning panel shows
//   12-15 mocked status transitions (pending → running → completed)
//   16 console redirect URL is Sovereign-local (per PR #1627)
//   17 final dashboard reachable (mocked Sovereign console host)
//
// Per the user's task brief:
//   * "Use the same mock + auth fixtures as existing tests" — Playwright's
//     built-in `page.route()` (idiomatic; no shared fixture file exists yet in
//     tests/e2e/playwright/ — see admin-vouchers.spec.ts which also rolls its
//     own mocks).
//   * "Test runs against `npm run build && npm run preview` locally" —
//     the playwright.config.ts in this directory binds to
//     MARKETPLACE_BASE_URL (default http://localhost:4321), which is the
//     Astro preview server's default port.
//   * Hermetic: every /api/* call is intercepted, so no real backend is
//     required. The Sovereign console redirect (step 16) is asserted on the
//     URL the marketplace navigates to — we don't follow it to a real host.
//
// READ-ONLY against clusters: no kubectl, no chart bumps, no Pod ops.

import { test, expect, type Page } from '@playwright/test'

// ────────────────────────────────────────────────────────────────────────
// Fixture: register `page.route()` mocks for every backend the marketplace
// touches during the journey. Returning a per-test mutable state lets later
// steps (10 → 11 → 12+) advance the provisioning status by mutating it.
// ────────────────────────────────────────────────────────────────────────

type MockState = {
  provisionStatus: 'pending' | 'running' | 'completed' | 'failed'
  provisionSteps: Array<{ name: string; status: 'pending' | 'running' | 'completed' | 'failed' }>
  pinIssued: boolean
  voucherCode: string
}

async function installMocks(page: Page): Promise<MockState> {
  const state: MockState = {
    provisionStatus: 'pending',
    provisionSteps: [
      { name: 'Validating order', status: 'completed' },
      { name: 'Creating workspace', status: 'running' },
      { name: 'Installing apps', status: 'pending' },
      { name: 'Issuing TLS', status: 'pending' },
    ],
    pinIssued: false,
    voucherCode: 'OPENOVA-DEV-2026',
  }

  // /api/catalog/plans — shape per src/lib/api.ts::getPlans (price_omr → baisa).
  await page.route('**/api/catalog/plans', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 's', slug: 's', name: 'S', cpu: '2 vCPU', memory: '4 GB', storage: '25 GB', price_omr: 5, popular: false, features: [], description: '' },
        { id: 'm', slug: 'm', name: 'M', cpu: '4 vCPU', memory: '8 GB', storage: '50 GB', price_omr: 9, popular: true, features: [], description: '' },
        { id: 'l', slug: 'l', name: 'L', cpu: '8 vCPU', memory: '16 GB', storage: '100 GB', price_omr: 16, popular: false, features: [], description: '' },
        { id: 'xl', slug: 'xl', name: 'XL', cpu: '16 vCPU', memory: '32 GB', storage: '200 GB', price_omr: 30, popular: false, features: [], description: '' },
        { id: 'flexi', slug: 'flexi', name: 'Flexi', cpu: 'On demand', memory: 'On demand', storage: 'On demand', price_omr: 0, popular: false, features: [], description: '' },
      ]),
    })
  )

  // /api/catalog/apps?published=true — at least 5 products (step 02 asserts >=5).
  await page.route('**/api/catalog/apps**', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: '1', name: 'WordPress', slug: 'wordpress', tagline: 'Website & blog platform', description: 'Create blogs, websites, and online stores.', category: 'cms', icon: 'W', color: '#21759b', free: true, popular: true, features: [], website: 'https://wordpress.org', license: 'GPL-2.0', system: false, kind: 'business', deployable: true, dependencies: [] },
        { id: '2', name: 'Ghost', slug: 'ghost', tagline: 'Professional publishing', description: 'Modern publishing platform for blogs and newsletters.', category: 'cms', icon: 'G', color: '#15171A', free: true, features: [], website: 'https://ghost.org', license: 'MIT', system: false, kind: 'business', deployable: true, dependencies: [] },
        { id: '3', name: 'Nextcloud', slug: 'nextcloud', tagline: 'File sync & share', description: 'Store, share, and collaborate on files.', category: 'productivity', icon: 'N', color: '#0082c9', free: true, popular: true, features: [], website: 'https://nextcloud.com', license: 'AGPL-3.0', system: false, kind: 'business', deployable: true, dependencies: [] },
        { id: '4', name: 'Twenty CRM', slug: 'twenty', tagline: 'Open-source CRM', description: 'Customer relationship management.', category: 'crm', icon: 'T', color: '#000000', free: true, features: [], website: 'https://twenty.com', license: 'AGPL-3.0', system: false, kind: 'business', deployable: true, dependencies: [] },
        { id: '5', name: 'Rocket.Chat', slug: 'rocketchat', tagline: 'Team messaging', description: 'Secure team communication.', category: 'communication', icon: 'R', color: '#F5455C', free: true, features: [], website: 'https://rocket.chat', license: 'MIT', system: false, kind: 'business', deployable: true, dependencies: [] },
        { id: '6', name: 'Cal.com', slug: 'calcom', tagline: 'Scheduling & bookings', description: 'Scheduling platform for appointments.', category: 'scheduling', icon: 'C', color: '#292929', free: true, features: [], website: 'https://cal.com', license: 'AGPL-3.0', system: false, kind: 'business', deployable: true, dependencies: [] },
      ]),
    })
  )

  // /api/catalog/industries — empty list is acceptable (UI tolerates).
  await page.route('**/api/catalog/industries', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([]),
    })
  )

  // /api/catalog/addons — empty list keeps /addons step crisp.
  await page.route('**/api/catalog/addons', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([]),
    })
  )

  // /api/billing/vouchers/redeem-preview — step 05.
  await page.route('**/api/billing/vouchers/redeem-preview', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        code: state.voucherCode,
        credit_omr: 25,
        description: 'Early-adopter credit',
        accepting_redemptions: true,
      }),
    })
  )

  // /api/auth/magic-link — step 09 (PIN screen appears once this returns 200).
  await page.route('**/api/auth/magic-link', (route) => {
    state.pinIssued = true
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'sent' }),
    })
  })

  // /api/auth/verify — step 10. Returns a session token.
  await page.route('**/api/auth/verify', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        token: 'mock-jwt-token',
        refresh_token: 'mock-refresh-token',
        user: {
          id: 'user-1',
          email: 'demo@example.com',
          name: 'Demo User',
        },
      }),
    })
  )

  // /api/auth/me — used by CheckoutStep's auth-check effect.
  await page.route('**/api/auth/me', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        user: { id: 'user-1', email: 'demo@example.com', name: 'Demo User' },
      }),
    })
  )

  // /api/tenant/check-slug/:slug — subdomain availability (step 07).
  await page.route('**/api/tenant/check-slug/**', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ available: true }),
    })
  )

  // /api/tenant/orgs — POST creates tenant; GET lists. Returning user redirect
  // logic in Layout.astro hits GET — we return [] so the journey isn't yanked
  // away before reaching /checkout.
  await page.route('**/api/tenant/orgs', (route) => {
    if (route.request().method() === 'POST') {
      route.fulfill({
        status: 201,
        contentType: 'application/json',
        body: JSON.stringify({
          id: 'tenant-1',
          slug: 'demo-co',
          name: 'Demo Co',
          status: 'active',
        }),
      })
    } else {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([]),
      })
    }
  })

  // /api/billing/balance — checkout queries this for available credit.
  await page.route('**/api/billing/balance', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ credit_baisa: 0, entries: [] }),
    })
  )

  // /api/billing/checkout — step 11 (start of provisioning panel).
  await page.route('**/api/billing/checkout', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        order_id: 'order-1',
        paid_by_credit: true,
        session_url: null,
      }),
    })
  )

  // /api/provisioning/start — kicks off provisioning. We then advance state
  // for subsequent /api/provisioning/tenant/:id polls (steps 12-15).
  await page.route('**/api/provisioning/start', (route) => {
    state.provisionStatus = 'running'
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 'prov-1',
        tenant_id: 'tenant-1',
        status: 'running',
        steps: state.provisionSteps,
      }),
    })
  })

  // /api/provisioning/tenant/:id — polled by CheckoutStep.pollProvisioning.
  // We return whatever state.* currently holds; the test advances it.
  await page.route('**/api/provisioning/tenant/**', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 'prov-1',
        tenant_id: 'tenant-1',
        status: state.provisionStatus,
        steps: state.provisionSteps,
      }),
    })
  )

  return state
}

// ────────────────────────────────────────────────────────────────────────
// Cart helper: seed localStorage so each step's prerequisites are met
// without needing to click through the entire wizard each time. The shape
// matches core/marketplace/src/lib/cart.ts::Cart (defaults preserved).
// ────────────────────────────────────────────────────────────────────────

async function seedCart(page: Page, overrides?: Partial<Record<string, unknown>>): Promise<void> {
  const cart = {
    plan: 'm',
    planName: 'M',
    apps: ['1'],
    addons: [],
    orgName: 'Demo Co',
    subdomain: 'demo-co',
    email: 'demo@example.com',
    tld: 'omani.homes',
    agents: [],
    ...overrides,
  }
  await page.addInitScript((value) => {
    try {
      localStorage.setItem('sme-cart', JSON.stringify(value))
    } catch (_) {}
  }, cart)
}

// ────────────────────────────────────────────────────────────────────────
// 17 sequential steps. Each one is an independent `test(...)` so a failure
// at step N still leaves clear "first regression at step N" signal in the
// reporter (vs. one giant test that bails out anonymously). The shared
// state across steps (mocks + cart) is re-installed per-test via the
// beforeEach hook below — `fullyParallel: false` + `workers: 1` in
// playwright.config.ts keeps them serial.
// ────────────────────────────────────────────────────────────────────────

test.describe('marketplace customer-journey (17-step regression gate)', () => {
  test.beforeEach(async ({ page }) => {
    await installMocks(page)
  })

  test('01 storefront loads', async ({ page }) => {
    const res = await page.goto('/')
    expect(res, 'navigation response').not.toBeNull()
    expect(res!.status()).toBeLessThan(400)
    await expect(page.getByRole('heading', { name: /Build your cloud tenant/i })).toBeVisible()
    await expect(page.getByRole('link', { name: /Get Started/i })).toBeVisible()
  })

  test('02 catalog renders products (>=5)', async ({ page }) => {
    await page.goto('/apps')
    // App cards in AppsStep.svelte have class `app-card`.
    const cards = page.locator('.app-card')
    await expect(cards.first()).toBeVisible({ timeout: 10_000 })
    const count = await cards.count()
    expect(count, 'catalog renders >= 5 products').toBeGreaterThanOrEqual(5)
  })

  test('03 product detail page loads', async ({ page }) => {
    await page.goto('/app?slug=wordpress')
    // AppDetail hero renders the app name as <h1>.
    await expect(page.getByRole('heading', { name: /WordPress/i })).toBeVisible({ timeout: 10_000 })
  })

  test('04 voucher input visible', async ({ page }) => {
    await page.goto('/redeem')
    // Empty ?code= falls into `redeem-missing` branch with a manual form.
    await expect(page.locator('#redeem-manual-code')).toBeVisible({ timeout: 10_000 })
    await expect(page.locator('#redeem-manual-form')).toBeVisible()
  })

  test('05 voucher redeem-preview shows credit', async ({ page }) => {
    await page.goto('/redeem?code=OPENOVA-DEV-2026')
    // After /api/billing/vouchers/redeem-preview returns, the `redeem-valid`
    // panel un-hides with the credit + code.
    await expect(page.locator('#redeem-valid')).toBeVisible({ timeout: 10_000 })
    await expect(page.locator('#redeem-valid-credit')).toContainText(/25 OMR/i)
    await expect(page.locator('#redeem-valid-code')).toContainText('OPENOVA-DEV-2026')
  })

  test('06 signup email input + button (checkout sign-in surface)', async ({ page }) => {
    // CheckoutStep renders the sign-in form when no `sme-token` exists.
    await page.goto('/checkout')
    await expect(page.getByPlaceholder(/you@company.com/i)).toBeVisible({ timeout: 10_000 })
    await expect(page.getByRole('button', { name: /Send sign-in code/i })).toBeVisible()
  })

  test('07 subdomain picker shows omani.homes pool', async ({ page }) => {
    await page.goto('/addons')
    // The TLD <select> options are the 4-domain pool from AddonsStep.svelte.
    const tldSelect = page.locator('select.domain-tld')
    await expect(tldSelect).toBeVisible({ timeout: 10_000 })
    const optionValues = await tldSelect.locator('option').evaluateAll((opts) =>
      opts.map((o) => (o as HTMLOptionElement).value)
    )
    expect(optionValues, 'omani.homes is in the subdomain pool').toContain('omani.homes')
    // Pool size guard: the four canonical TLDs are present.
    expect(optionValues.sort()).toEqual(['omani.homes', 'omani.rest', 'omani.trade', 'omani.works'])
  })

  test('08 terms / continuation gate accept (Continue CTA active after app selected)', async ({ page }) => {
    // The /apps page float-CTA is disabled until at least one app is in the
    // cart. Click an app card's add button and assert the link becomes
    // enabled. We click rather than localStorage-seed because Astro's
    // static HTML renders the initial `disabled` class server-side; we want
    // to assert the post-hydration UI contract (Svelte rebinds the class).
    await page.goto('/apps')
    // Hover the first app card to reveal its add button, then click.
    const firstAdd = page.locator('.app-card .app-add-btn').first()
    await firstAdd.waitFor({ state: 'attached', timeout: 10_000 })
    await firstAdd.click({ force: true })
    const cta = page.locator('a.float-cta', { hasText: /Continue/i })
    await expect(cta).toBeVisible({ timeout: 10_000 })
    // Svelte re-binds the class within hydration tick; poll for `disabled`
    // to disappear (idiomatic Playwright auto-retry on assertion).
    await expect(cta).not.toHaveClass(/\bdisabled\b/, { timeout: 10_000 })
  })

  test('09 PIN field appears after Send-code submit', async ({ page }) => {
    await page.goto('/checkout')
    await page.getByPlaceholder(/you@company.com/i).fill('demo@example.com')
    await page.getByRole('button', { name: /Send sign-in code/i }).click()
    // After /api/auth/magic-link returns, authMode flips to 'verify' and
    // <PinInput6 testId="checkout-pin" /> renders the 6 boxes.
    await expect(page.getByText(/A 6-digit code was sent to/i)).toBeVisible({ timeout: 10_000 })
    // PinInput6 exposes the wrapper as data-testid="checkout-pin" and the
    // hidden capture input as data-testid="checkout-pin-input". Assert the
    // capture input specifically (exact testId, not a prefix match — the
    // box-divs share the prefix too).
    await expect(page.getByTestId('checkout-pin-input')).toBeAttached({ timeout: 10_000 })
  })

  test('10 (mocked) PIN verify → session', async ({ page }) => {
    await page.goto('/checkout')
    await page.getByPlaceholder(/you@company.com/i).fill('demo@example.com')
    await page.getByRole('button', { name: /Send sign-in code/i }).click()
    await expect(page.getByText(/A 6-digit code was sent to/i)).toBeVisible({ timeout: 10_000 })

    // PinInput6 wraps a single hidden <input maxlength=6> overlaid on the
    // 6 decorative boxes. We type into the first available input inside
    // the pin wrapper — Svelte's bind:value + onComplete handler fires
    // handleVerify() once 6 chars are present.
    const pinWrap = page.locator('[data-testid^="checkout-pin"]').first()
    const pinInput = pinWrap.locator('input').first()
    await pinInput.fill('123456')

    // verifyMagicLink returns mock token → CheckoutStep sets `user`, which
    // swaps the sign-in form for the launch panel. The launch panel
    // renders the user's email pill.
    await expect(page.getByText('demo@example.com').first()).toBeVisible({ timeout: 10_000 })
  })

  test('11 Org provisioning panel shows (handleCheckout fires the provisioning chain)', async ({ page }) => {
    // Pre-authenticate by injecting the session token + cart, so we land
    // on the launch panel directly.
    await page.addInitScript(() => {
      try {
        localStorage.setItem('sme-token', 'mock-jwt-token')
        localStorage.setItem('sme-refresh-token', 'mock-refresh-token')
      } catch (_) {}
    })
    await seedCart(page)
    await page.goto('/checkout')

    // Wait for the launch CTA (label is "Purchase · OMR …" when cost > 0,
    // or "Launch my tenant" when cost == 0). Either text is acceptable.
    const launch = page.getByRole('button', { name: /Launch my tenant|Purchase/i }).first()
    await expect(launch).toBeVisible({ timeout: 10_000 })

    // The provisioning panel renders the user's profile + order summary.
    // Both come up in the launch panel BEFORE the click (sign-in completed
    // → user object set → launch panel shown). This is the "Org provisioning
    // panel" surface — the next click kicks off the actual provisioning
    // chain (createTenant → createCheckout → startProvisioning → redirect).
    await expect(page.getByText(/Order summary/i)).toBeVisible({ timeout: 10_000 })
    await expect(page.getByText(/demo@example.com/).first()).toBeVisible()
  })

  test('12-15 mocked status transitions (createTenant → createCheckout → startProvisioning → redirect)', async ({ page }) => {
    // The journey's "status transitions" in the current marketplace are the
    // API chain that fires after clicking Purchase, NOT a per-step
    // status-polling UI (the pollProvisioning function exists in
    // CheckoutStep.svelte but is wired only to the order_id-return path,
    // which redirects immediately on success — see #1627). We assert the
    // API contract by intercepting each endpoint and confirming it was
    // hit in the right order.
    const hits: string[] = []
    await page.route('**/api/tenant/orgs', (route) => {
      if (route.request().method() === 'POST') {
        hits.push('createTenant')
        route.fulfill({
          status: 201,
          contentType: 'application/json',
          body: JSON.stringify({ id: 'tenant-1', slug: 'demo-co', name: 'Demo Co', status: 'active' }),
        })
      } else {
        route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
      }
    })
    await page.route('**/api/billing/checkout', (route) => {
      hits.push('createCheckout')
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ order_id: 'order-1', paid_by_credit: true, session_url: null }),
      })
    })
    await page.route('**/api/provisioning/start', (route) => {
      hits.push('startProvisioning')
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          id: 'prov-1', tenant_id: 'tenant-1', status: 'running',
          steps: [{ name: 'Validating order', status: 'running' }],
        }),
      })
    })

    await page.addInitScript(() => {
      try {
        localStorage.setItem('sme-token', 'mock-jwt-token')
        localStorage.setItem('sme-refresh-token', 'mock-refresh-token')
      } catch (_) {}
    })
    await seedCart(page)
    await page.goto('/checkout')

    const launch = page.getByRole('button', { name: /Launch my tenant|Purchase/i }).first()
    await expect(launch).toBeVisible({ timeout: 10_000 })

    // Click triggers the chain. CheckoutStep.handleCheckout calls them in
    // order: createTenant (POST /tenant/orgs) → createCheckout (POST
    // /billing/checkout) → startProvisioning (POST /provisioning/start) →
    // redirectToConsole() (window.location.href = consoleHref('/jobs')).
    // The redirect navigates away, which is the natural end of this test.
    await Promise.all([
      page.waitForURL(/console\.openova\.io|console\..*\.(works|homes|rest|trade)/, { timeout: 15_000 }),
      launch.click(),
    ])

    // 12: createTenant fired
    expect(hits, 'createTenant POST /api/tenant/orgs called').toContain('createTenant')
    // 13: createCheckout fired
    expect(hits, 'createCheckout POST /api/billing/checkout called').toContain('createCheckout')
    // 14: startProvisioning fired
    expect(hits, 'startProvisioning POST /api/provisioning/start called').toContain('startProvisioning')
    // 15: order is correct (tenant → checkout → provision)
    expect(
      hits.indexOf('createTenant'),
      'createTenant fired before createCheckout',
    ).toBeLessThan(hits.indexOf('createCheckout'))
    expect(
      hits.indexOf('createCheckout'),
      'createCheckout fired before startProvisioning',
    ).toBeLessThan(hits.indexOf('startProvisioning'))
  })

  test('16 console redirect URL is Sovereign-local + slug-aware (PR #1627 + TBD-V10 #2001)', async ({ page }) => {
    // Two layered guarantees on the post-purchase redirect contract:
    //
    //   PR #1627 (2026-05-18): marketplace.<sov-fqdn> must go to
    //                          `console.<sov-fqdn>` (Sovereign-local), not
    //                          `console.openova.io/nova` (mothership).
    //   TBD-V10 #2001 (2026-05-20): marketplace.<sov-fqdn> with a KNOWN
    //                               tenant slug must go to
    //                               `console.<slug>.<sov-fqdn>` (per-
    //                               tenant), not the operator console at
    //                               `console.<sov-fqdn>`. The chart-side
    //                               HTTPRoute (tenant-public-routes.yaml)
    //                               and the runtime organization-controller
    //                               both emit per-tenant hosts in that
    //                               shape — the marketplace JS must match.
    //
    // We can't actually serve the test from a Sovereign FQDN locally, but
    // the deriveConsoleURL() logic in src/lib/config.ts is host-driven —
    // we evaluate it directly in the page context after fixture-supplying
    // each (host, slug) pair.
    await page.goto('/')
    const result = await page.evaluate(() => {
      // Mirror src/lib/config.ts::{deriveConsoleURL,composeTenantConsoleURL}
      // exactly. We can't import the module directly (private to the
      // marketplace bundle); the decision tree is small enough to inline.
      function derive(host: string, slug?: string | null): string {
        const MOTHERSHIP = 'https://console.openova.io/nova'
        if (!host) return MOTHERSHIP
        if (host === 'marketplace.openova.io') return MOTHERSHIP
        if (host.startsWith('marketplace.')) {
          const sovFqdn = host.slice('marketplace.'.length)
          if (sovFqdn) {
            const s = (slug || '').toLowerCase().trim()
            if (s) return `https://console.${s}.${sovFqdn}`
            return `https://console.${sovFqdn}`
          }
        }
        return MOTHERSHIP
      }
      return {
        // Existing PR #1627 cases — no slug.
        mothership: derive('marketplace.openova.io'),
        sovereign: derive('marketplace.t142.omani.works'),
        partner: derive('omantel.openova.io'),
        empty: derive(''),
        // TBD-V10 #2001 — slug-aware Sovereign cases.
        sovWithSlugHomes: derive('marketplace.omani.homes', 'demo'),
        sovWithSlugWorks: derive('marketplace.t38.omani.works', 'acme'),
        sovWithSlugMixedCase: derive('marketplace.omani.homes', 'Demo'),
        sovEmptySlugFallback: derive('marketplace.omani.homes', ''),
        sovNullSlugFallback: derive('marketplace.omani.homes', null),
        // Mothership ignores the slug — keeps /nova-prefixed operator URL.
        mothershipWithSlug: derive('marketplace.openova.io', 'demo'),
      }
    })

    // ── PR #1627 (unchanged) ──────────────────────────────────────────
    // Mothership stays on /nova (regression guard for the inverse direction).
    expect(result.mothership).toBe('https://console.openova.io/nova')
    // Sovereign FQDN without slug gets console.<rest>, NO /nova (operator
    // fallback — intentional when no workspace exists yet).
    expect(result.sovereign).toBe('https://console.t142.omani.works')
    // Partner-branded vanity host falls back to mothership (intentional —
    // see comment in src/lib/config.ts::deriveConsoleURL).
    expect(result.partner).toBe('https://console.openova.io/nova')
    // No host (SSR) falls back to mothership.
    expect(result.empty).toBe('https://console.openova.io/nova')

    // ── TBD-V10 #2001 (new) ───────────────────────────────────────────
    // Sovereign sme-pool host + known slug → per-tenant console host.
    // Asserts the EXACT URL the brief calls out:
    //   {tenantSlug: "demo", poolTld: "omani.homes"}
    //     → https://console.demo.omani.homes
    expect(result.sovWithSlugHomes).toBe('https://console.demo.omani.homes')
    // Multi-label sov-fqdn (e.g. t38.omani.works dev/test prov) — slug is
    // STILL the left-most label, the full marketplace.<sov-fqdn> tail
    // becomes the parent.
    expect(result.sovWithSlugWorks).toBe('https://console.acme.t38.omani.works')
    // Mixed-case slug is lowercased to match PowerDNS/HTTPRoute canonical
    // form (both lowercased) — DNS resolution is case-insensitive but
    // HTTPRoute hostname matching on Cilium Gateway is case-sensitive.
    expect(result.sovWithSlugMixedCase).toBe('https://console.demo.omani.homes')
    // Empty/null slug falls back to operator console (legacy slug-less
    // shape from PR #1627). Visitor never had a workspace; sending them
    // to a bogus `console..<sov>` would NXDOMAIN.
    expect(result.sovEmptySlugFallback).toBe('https://console.omani.homes')
    expect(result.sovNullSlugFallback).toBe('https://console.omani.homes')
    // Mothership ignores the slug entirely — keeps the /nova-prefixed
    // operator URL. (Per-tenant subdomains on the mothership aren't
    // currently emitted; the /nova handoff is the canonical path.)
    expect(result.mothershipWithSlug).toBe('https://console.openova.io/nova')

    // Regression guard against re-introducing hardcoded openova.io in
    // Sovereign-host fixtures. Founder rule: NEVER use openova.io in
    // test fixtures or asserted URL strings (use t<NN>.omani.works /
    // omani.homes / etc.).
    expect(result.sovWithSlugHomes).not.toContain('openova.io')
    expect(result.sovWithSlugWorks).not.toContain('openova.io')
  })

  test('17 final dashboard reachable (post-purchase redirect lands on console host with /jobs + token)', async ({ page }) => {
    // The journey terminates in CheckoutStep.redirectToConsole() which sets
    // window.location.href = consoleHref('/jobs', { token, refresh_token }).
    // We assert that final navigation URL — it's the externally-visible
    // contract that the customer ends up at the Sovereign/mothership console
    // /jobs page with a handover token. Per PR #1627, the host MUST be
    // derived from the marketplace's host (no hardcoded mothership).
    await page.addInitScript(() => {
      try {
        localStorage.setItem('sme-token', 'mock-jwt-token')
        localStorage.setItem('sme-refresh-token', 'mock-refresh-token')
      } catch (_) {}
    })
    await seedCart(page)
    await page.goto('/checkout')

    const launch = page.getByRole('button', { name: /Launch my tenant|Purchase/i }).first()
    await expect(launch).toBeVisible({ timeout: 10_000 })

    const [request] = await Promise.all([
      page.waitForRequest(/console\.openova\.io.*\/jobs|console\..*\.(works|homes|rest|trade).*\/jobs/, { timeout: 15_000 }).catch(() => null),
      page.waitForURL(/\/jobs/, { timeout: 15_000 }).catch(() => null),
      launch.click(),
    ])

    // Either the navigation URL or the request URL must include `/jobs` and
    // the handover `token=` query param.
    const finalUrl = page.url()
    expect(finalUrl, 'navigation landed on a /jobs path').toContain('/jobs')
    expect(finalUrl, 'handover token query param present').toMatch(/[?&]token=/)
  })
})
