/**
 * A1 — integration tests for the Postgres store adapter (src/store/pg.ts) + migration runner
 * (src/db/migrate.ts). Exercises EVERY port method and the tricky semantics that must match
 * store/memory.ts: INV8 version history, casRedeem single-winner, registerFailure, subdomain
 * reserve/isTaken, route TTL fail-closed + listByNode, append-only metering/audit time windows.
 *
 * Gated on PG_TEST_URL. If unset, a disposable postgres:16 container is started on port 5433.
 */
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { execSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { setTimeout as sleep } from 'node:timers/promises'
import { createPgPool, createQuery, type QueryFn } from '../../src/db/pool.js'
import { runMigrations } from '../../src/db/migrate.js'
import { createPgStores } from '../../src/store/pg.js'
import type { Stores } from '../../src/store/ports.js'
import type { AccountRecord, HostRecord } from '../../src/model/records.js'

const CONTAINER = 'cp_pg_a1_test'
const PORT = 5433
const READY_TIMEOUT_MS = 60_000

let pool: ReturnType<typeof createPgPool> | undefined
let query: QueryFn
let stores: Stores
let startedContainer = false

beforeAll(async () => {
  let url = process.env.PG_TEST_URL
  if (!url) {
    try {
      execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
    } catch {
      /* nothing to remove */
    }
    execSync(
      `docker run -d --rm --name ${CONTAINER} -e POSTGRES_PASSWORD=test -e POSTGRES_DB=cp -p ${PORT}:5432 postgres:16`,
      { stdio: 'ignore' },
    )
    startedContainer = true
    url = `postgres://postgres:test@127.0.0.1:${PORT}/cp`
  }

  pool = createPgPool(url)
  query = createQuery(pool)

  // Poll until the server accepts queries.
  const deadline = Date.now() + READY_TIMEOUT_MS
  for (;;) {
    try {
      await query('SELECT 1', [])
      break
    } catch (err) {
      if (Date.now() > deadline) throw err
      await sleep(500)
    }
  }

  await runMigrations(query)
  // Idempotency: a second run must not throw (all migrations are IF NOT EXISTS).
  await runMigrations(query)
  stores = createPgStores(query)
}, 180_000)

afterAll(async () => {
  await pool?.end()
  if (startedContainer) {
    try {
      execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' })
    } catch {
      /* best effort */
    }
  }
})

// ---- fixtures ---------------------------------------------------------------------------------

function newAccount(): AccountRecord {
  return { accountId: randomUUID(), plan: 'free', createdAt: new Date().toISOString(), status: 'active' }
}
async function makeAccount(): Promise<AccountRecord> {
  const rec = newAccount()
  await stores.accounts.insert(rec)
  return rec
}
async function makeHost(accountId: string, subdomain = `sub-${randomUUID().slice(0, 8)}`): Promise<HostRecord> {
  const rec: HostRecord = {
    hostId: randomUUID(),
    accountId,
    subdomain,
    agentPubkey: new Uint8Array([1, 2, 3, 4]),
    enrollFpr: `fpr-${subdomain}`,
    status: 'offline',
    lastSeen: new Date().toISOString(),
    createdAt: new Date().toISOString(),
    revokedAt: null,
  }
  await stores.hosts.insert(rec)
  return rec
}

// ---- AccountStore -----------------------------------------------------------------------------

describe('AccountStore', () => {
  test('insert + get round-trips the record', async () => {
    const rec = await makeAccount()
    expect(await stores.accounts.get(rec.accountId)).toEqual(rec)
  })

  test('get returns null for unknown account', async () => {
    expect(await stores.accounts.get(randomUUID())).toBeNull()
  })

  test('duplicate insert throws', async () => {
    const rec = await makeAccount()
    await expect(stores.accounts.insert(rec)).rejects.toThrow('duplicate accountId')
  })

  test('swapStatus bumps status + appends INV8 version history atomically', async () => {
    const rec = await makeAccount()
    expect(await stores.accounts.versions(rec.accountId)).toEqual([
      { accountId: rec.accountId, version: 1, status: 'active', changedAt: rec.createdAt, changedBy: 'system' },
    ])

    const swapped = await stores.accounts.swapStatus(rec.accountId, 'suspended', 'admin-1')
    expect(swapped.status).toBe('suspended')
    expect(swapped.accountId).toBe(rec.accountId)
    expect((await stores.accounts.get(rec.accountId))?.status).toBe('suspended')

    const versions = await stores.accounts.versions(rec.accountId)
    expect(versions.length).toBe(2)
    expect(versions[1]).toMatchObject({ version: 2, status: 'suspended', changedBy: 'admin-1' })
  })

  test('swapStatus on unknown account throws', async () => {
    await expect(stores.accounts.swapStatus(randomUUID(), 'suspended', 'x')).rejects.toThrow('account not found')
  })
})

// ---- HostStore --------------------------------------------------------------------------------

describe('HostStore', () => {
  test('insert + get + getBySubdomain + listByAccount', async () => {
    const acct = await makeAccount()
    const host = await makeHost(acct.accountId)
    expect(await stores.hosts.get(host.hostId)).toEqual(host)
    expect(await stores.hosts.getBySubdomain(host.subdomain)).toEqual(host)
    expect(await stores.hosts.listByAccount(acct.accountId)).toEqual([host])
  })

  test('get / getBySubdomain return null when absent', async () => {
    expect(await stores.hosts.get(randomUUID())).toBeNull()
    expect(await stores.hosts.getBySubdomain(`missing-${randomUUID()}`)).toBeNull()
  })

  test('duplicate hostId and duplicate subdomain throw distinctly', async () => {
    const acct = await makeAccount()
    const host = await makeHost(acct.accountId)
    await expect(stores.hosts.insert(host)).rejects.toThrow('duplicate hostId')

    const clash: HostRecord = { ...host, hostId: randomUUID() }
    await expect(stores.hosts.insert(clash)).rejects.toThrow('duplicate subdomain')
  })

  test('swapStatus to revoked sets revokedAt + version history', async () => {
    const acct = await makeAccount()
    const host = await makeHost(acct.accountId)
    const revokedAt = new Date().toISOString()
    const swapped = await stores.hosts.swapStatus(host.hostId, 'revoked', revokedAt, 'admin-2')
    expect(swapped.status).toBe('revoked')
    expect(swapped.revokedAt).toBe(revokedAt)

    const versions = await stores.hosts.versions(host.hostId)
    expect(versions.length).toBe(2)
    expect(versions[0]).toMatchObject({ version: 1, status: 'offline', revokedAt: null, changedBy: 'system' })
    expect(versions[1]).toMatchObject({ version: 2, status: 'revoked', revokedAt, changedBy: 'admin-2' })
  })

  test('swapStatus on unknown host throws', async () => {
    await expect(stores.hosts.swapStatus(randomUUID(), 'online', null, 'x')).rejects.toThrow('host not found')
  })

  test('touchLastSeen updates the advisory cache (no version row)', async () => {
    const acct = await makeAccount()
    const host = await makeHost(acct.accountId)
    const ts = new Date(Date.now() + 5000).toISOString()
    await stores.hosts.touchLastSeen(host.hostId, ts)
    expect((await stores.hosts.get(host.hostId))?.lastSeen).toBe(ts)
    expect((await stores.hosts.versions(host.hostId)).length).toBe(1) // unchanged
    await stores.hosts.touchLastSeen(randomUUID(), ts) // no-op on absent host, no throw
  })
})

// ---- SessionStore -----------------------------------------------------------------------------

describe('SessionStore', () => {
  test('insert + get + duplicate + missing', async () => {
    const acct = await makeAccount()
    const host = await makeHost(acct.accountId)
    const rec = {
      sessionId: randomUUID(),
      hostId: host.hostId,
      accountId: acct.accountId,
      createdAt: new Date().toISOString(),
      lastAttachAt: new Date().toISOString(),
    }
    await stores.sessions.insert(rec)
    expect(await stores.sessions.get(rec.sessionId)).toEqual(rec)
    await expect(stores.sessions.insert(rec)).rejects.toThrow('duplicate sessionId')
    expect(await stores.sessions.get(randomUUID())).toBeNull()
  })
})

// ---- SubdomainStore ---------------------------------------------------------------------------

describe('SubdomainStore', () => {
  test('reserve/isTaken reflect host ownership of the label', async () => {
    const sub = `claim-${randomUUID().slice(0, 8)}`
    expect(await stores.subdomains.isTaken(sub)).toBe(false)
    expect(await stores.subdomains.reserve(sub)).toBe(true) // free (no host owns it yet)

    const acct = await makeAccount()
    await makeHost(acct.accountId, sub)

    expect(await stores.subdomains.isTaken(sub)).toBe(true)
    expect(await stores.subdomains.reserve(sub)).toBe(false) // a host now owns it
  })
})

// ---- PairingStore -----------------------------------------------------------------------------

describe('PairingStore', () => {
  test('insert + get + casRedeem single-winner + registerFailure', async () => {
    const acct = await makeAccount()
    const codeHash = `code-${randomUUID()}`
    const rec = {
      codeHash,
      accountId: acct.accountId,
      expiresAt: new Date(Date.now() + 60_000).toISOString(),
      redeemedAt: null,
    }
    await stores.pairing.insert(rec)
    await expect(stores.pairing.insert(rec)).rejects.toThrow('duplicate code_hash')

    const got = await stores.pairing.get(codeHash)
    expect(got).toEqual({ record: rec, redeemAttempts: 0 })

    const now = new Date().toISOString()
    expect(await stores.pairing.casRedeem(codeHash, now)).toBe('ok')
    expect(await stores.pairing.casRedeem(codeHash, now)).toBe('already_redeemed')
    expect(await stores.pairing.casRedeem(`unknown-${randomUUID()}`, now)).toBe('unknown')
    expect((await stores.pairing.get(codeHash))?.record.redeemedAt).toBe(now)
  })

  test('registerFailure increments; unknown code returns 0', async () => {
    const acct = await makeAccount()
    const codeHash = `code-${randomUUID()}`
    await stores.pairing.insert({
      codeHash,
      accountId: acct.accountId,
      expiresAt: new Date(Date.now() + 60_000).toISOString(),
      redeemedAt: null,
    })
    expect(await stores.pairing.registerFailure(codeHash)).toBe(1)
    expect(await stores.pairing.registerFailure(codeHash)).toBe(2)
    expect((await stores.pairing.get(codeHash))?.redeemAttempts).toBe(2)
    expect(await stores.pairing.registerFailure(`unknown-${randomUUID()}`)).toBe(0)
  })

  test('get returns null for unknown code', async () => {
    expect(await stores.pairing.get(`nope-${randomUUID()}`)).toBeNull()
  })
})

// ---- RouteStore -------------------------------------------------------------------------------

describe('RouteStore', () => {
  test('set → get → refreshTtl → delete', async () => {
    const hostId = randomUUID()
    const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
    await stores.routes.set(hostId, entry, 60)
    expect(await stores.routes.get(hostId)).toEqual(entry)
    expect(await stores.routes.refreshTtl(hostId, 60)).toBe(true)
    await stores.routes.delete(hostId)
    expect(await stores.routes.get(hostId)).toBeNull()
  })

  test('expired route fails closed (get null, refreshTtl false)', async () => {
    const hostId = randomUUID()
    const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() }
    await stores.routes.set(hostId, entry, -1) // already expired
    expect(await stores.routes.get(hostId)).toBeNull()
    expect(await stores.routes.refreshTtl(hostId, 60)).toBe(false)
    expect(await stores.routes.refreshTtl(randomUUID(), 60)).toBe(false) // absent
  })

  test('listByNode returns only live routes targeting the node', async () => {
    const node = `node-${randomUUID().slice(0, 8)}`
    const live1 = randomUUID()
    const live2 = randomUUID()
    const expired = randomUUID()
    const otherNode = randomUUID()
    await stores.routes.set(live1, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
    await stores.routes.set(live2, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60)
    await stores.routes.set(expired, { relayNodeId: node, updatedAt: new Date().toISOString() }, -1)
    await stores.routes.set(otherNode, { relayNodeId: 'someone-else', updatedAt: new Date().toISOString() }, 60)

    const listed = await stores.routes.listByNode(node)
    const hostIds = listed.map((r) => r.hostId).sort()
    expect(hostIds).toEqual([live1, live2].sort())
    expect(listed.every((r) => r.entry.relayNodeId === node)).toBe(true)
  })
})

// ---- NodeStore --------------------------------------------------------------------------------

describe('NodeStore', () => {
  test('upsert + get + setStatus + touch + delete', async () => {
    const nodeId = `relay-${randomUUID().slice(0, 8)}`
    const row = { nodeId, addr: '10.0.0.1:9000', status: 'active' as const, lastSeen: new Date().toISOString() }
    await stores.nodes.upsert(row)
    expect(await stores.nodes.get(nodeId)).toEqual(row)

    await stores.nodes.upsert({ ...row, addr: '10.0.0.2:9000' }) // upsert overwrites
    expect((await stores.nodes.get(nodeId))?.addr).toBe('10.0.0.2:9000')

    await stores.nodes.setStatus(nodeId, 'draining')
    expect((await stores.nodes.get(nodeId))?.status).toBe('draining')

    const later = new Date(Date.now() + 1000).toISOString()
    await stores.nodes.touch(nodeId, later)
    expect((await stores.nodes.get(nodeId))?.lastSeen).toBe(later)

    await stores.nodes.delete(nodeId)
    expect(await stores.nodes.get(nodeId)).toBeNull()
  })

  test('setStatus on unknown node throws; touch is a silent no-op', async () => {
    await expect(stores.nodes.setStatus(`ghost-${randomUUID()}`, 'draining')).rejects.toThrow('node not found')
    await stores.nodes.touch(`ghost-${randomUUID()}`, new Date().toISOString()) // no throw
  })
})

// ---- MeteringStore ----------------------------------------------------------------------------

describe('MeteringStore', () => {
  test('append-only + inclusive [from,to] time-window query filtered by account', async () => {
    const acct = await makeAccount()
    const other = await makeAccount()
    const host = await makeHost(acct.accountId)
    const otherHost = await makeHost(other.accountId)

    const t0 = '2026-01-01T00:00:00.000Z'
    const t1 = '2026-01-01T01:00:00.000Z'
    const t2 = '2026-01-01T02:00:00.000Z'

    await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 3, sampledAt: t0 })
    await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 5, sampledAt: t1 })
    await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 9, sampledAt: t2 })
    await stores.metering.append({ hostId: otherHost.hostId, accountId: other.accountId, nodeId: 'n1', concurrentViewers: 1, sampledAt: t1 })

    const inWindow = await stores.metering.query(acct.accountId, t0, t1)
    expect(inWindow.map((r) => r.concurrentViewers)).toEqual([3, 5]) // t2 excluded, other acct excluded
    expect(inWindow[0]).toMatchObject({ hostId: host.hostId, accountId: acct.accountId, sampledAt: t0 })

    const all = await stores.metering.query(acct.accountId, t0, t2)
    expect(all.length).toBe(3)
  })
})

// ---- AuditStore -------------------------------------------------------------------------------

describe('AuditStore', () => {
  test('append-only + time-window query + null hostId + meta jsonb round-trip', async () => {
    const accountId = randomUUID()
    const t0 = '2026-02-01T00:00:00.000Z'
    const t1 = '2026-02-01T01:00:00.000Z'
    const t2 = '2026-02-01T02:00:00.000Z'

    await stores.audit.append({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
    await stores.audit.append({ action: 'revoke', principalId: 'p2', accountId, hostId: null, ts: t1, meta: {} })
    await stores.audit.append({ action: 'late', principalId: 'p3', accountId, hostId: null, ts: t2, meta: {} })
    await stores.audit.append({ action: 'other', principalId: 'p9', accountId: randomUUID(), hostId: null, ts: t1, meta: {} })

    const rows = await stores.audit.query(accountId, t0, t1)
    expect(rows.map((r) => r.action)).toEqual(['provision', 'revoke']) // t2 + other account excluded
    expect(rows[0]).toEqual({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } })
    expect(rows[1]?.hostId).toBeNull()
  })
})
