import { spawn, type ChildProcess } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import postgres from 'postgres'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { auditLog, createDb, runMigrations, type DbHandle } from '@znet/catalog-db'
import { createLogger } from '@znet/shared'
import { MagentoApi, MagentoClient } from '@znet/magento'
import { buildApp } from '../apps/catalog-api/src/app.js'
import { syncInventory, syncProductsFull, syncProductsIncremental, syncReference, type SyncContext } from '../apps/sync/src/jobs.js'
import crypto from 'node:crypto'
import { ADMIN_OTP, ADMIN_USERS, fixtures, startMockMagento } from './mock-magento.js'

/**
 * End to end: mock Magento → sync → Postgres → Catalog API → MCP server (real process, static auth)
 * → MCP client. Needs a local Postgres; set TEST_DATABASE_URL to override.
 */
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const DB_URL = process.env.TEST_DATABASE_URL ?? 'postgres://localhost/mcp_znet_test'
const CATALOG_TOKEN = 'catalog-api-test-token-0123456789'
const TOKENS = {
  full: 'tok-full',
  catalog: 'tok-catalog',
  pii: 'tok-pii',
}

const data = fixtures()
let magento: Awaited<ReturnType<typeof startMockMagento>>
let handle: DbHandle
let ctx: SyncContext
let api: ReturnType<typeof buildApp>
let mcp: ChildProcess
let mcpUrl: string
let authMcp: ChildProcess
let authBase: string
let apiUrl: string

async function recreateDatabase(url: string) {
  const u = new URL(url)
  const name = u.pathname.slice(1)
  u.pathname = '/postgres'
  const admin = postgres(u.toString(), { max: 1, onnotice: () => {} })
  await admin.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`)
  await admin.unsafe(`CREATE DATABASE "${name}"`)
  await admin.end()
}

async function waitFor(url: string, ms = 20_000) {
  const until = Date.now() + ms
  while (Date.now() < until) {
    if (await fetch(url).then(r => r.ok).catch(() => false)) return
    await new Promise(r => setTimeout(r, 200))
  }
  throw new Error(`timed out waiting for ${url}`)
}

async function connect(token: string) {
  const client = new Client({ name: 'e2e', version: '0.0.0' })
  await client.connect(new StreamableHTTPClientTransport(new URL(mcpUrl), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }))
  return client
}

async function call(client: Client, name: string, args: Record<string, unknown> = {}) {
  const res = await client.callTool({ name, arguments: args })
  const text = (res.content as Array<{ type: string; text: string }>)[0]!.text
  if (res.isError) throw new Error(`${name} failed: ${text}`)
  return JSON.parse(text)
}

beforeAll(async () => {
  await recreateDatabase(DB_URL)
  await runMigrations(DB_URL)
  magento = await startMockMagento(data)
  handle = createDb(DB_URL, { max: 5 })
  const log = createLogger('e2e', 'warn')
  ctx = { db: handle.db, magento: new MagentoApi(new MagentoClient({ baseUrl: magento.url, accessToken: magento.token, maxRetries: 0 })), log, storeCodes: [], stockId: 1 }

  api = buildApp({ db: handle.db, token: CATALOG_TOKEN, defaultStore: 'default', logger: log })
  apiUrl = await api.listen({ host: '127.0.0.1', port: 0 })

  const port = 4300 + Math.floor(Math.random() * 300)
  mcpUrl = `http://127.0.0.1:${port}/mcp`
  mcp = spawnMcp(port, {
    AUTH_MODE: 'static',
    MCP_STATIC_TOKENS: [
      `${TOKENS.full}|alice|catalog:read orders:read`,
      `${TOKENS.catalog}|bob|catalog:read`,
      `${TOKENS.pii}|carol|catalog:read orders:read orders:pii`,
    ].join(';'),
  })

  // A second instance acting as its own OAuth server, with Magento admin sign-in.
  const authPort = port + 300
  authBase = `http://127.0.0.1:${authPort}`
  authMcp = spawnMcp(authPort, {
    AUTH_MODE: 'magento',
    MCP_AUTH_SECRET: 'test-secret-0123456789-0123456789-abcdef',
    MAGENTO_ADMIN_2FA: 'google',
    MCP_USER_SCOPES: 'alice=catalog:read orders:read orders:pii;*=catalog:read',
  })
  await Promise.all([waitFor(`http://127.0.0.1:${port}/health`), waitFor(`${authBase}/health`)])
})

function spawnMcp(port: number, extra: Record<string, string>) {
  return spawn(path.join(ROOT, 'node_modules/.bin/tsx'), ['--conditions=znet-source', 'apps/mcp-server/src/index.ts'], {
    cwd: ROOT,
    stdio: ['ignore', 'inherit', 'inherit'],
    env: {
      ...process.env,
      NODE_ENV: 'test',
      LOG_LEVEL: 'error',
      DATABASE_URL: DB_URL,
      MAGENTO_BASE_URL: magento.url,
      MAGENTO_ACCESS_TOKEN: magento.token,
      CATALOG_API_URL: apiUrl,
      CATALOG_API_TOKEN: CATALOG_TOKEN,
      MCP_PUBLIC_URL: `http://127.0.0.1:${port}/mcp`,
      MCP_PORT: String(port),
      ...extra,
    },
  })
}

afterAll(async () => {
  mcp?.kill('SIGTERM')
  authMcp?.kill('SIGTERM')
  await api?.close()
  await handle?.close()
  await magento?.close()
})

describe('sync', () => {
  it('loads reference data, products, store views and inventory from Magento', async () => {
    const ref = await syncReference(ctx)
    expect(ref).toMatchObject({ stores: 2, attributes: 3, attributeSets: 1, categories: 4 })

    const full = await syncProductsFull(ctx)
    expect(full).toMatchObject({ magentoProducts: 5, 'products:all': 5, 'products:default': 5, softDeleted: 0 })

    const inv = await syncInventory(ctx)
    expect(inv).toMatchObject({ sourceItems: 5, msi: 1 })

    // The full sweep advanced cursors to its start time, so nothing is new yet.
    const inc = await syncProductsIncremental(ctx)
    expect(inc).toEqual({ 'products:all': 0, 'products:default': 0 })
  })
})

describe('MCP server', () => {
  it('rejects unauthenticated requests with a WWW-Authenticate challenge', async () => {
    const res = await fetch(mcpUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
    expect(res.status).toBe(401)
    expect(res.headers.get('www-authenticate')).toContain('resource_metadata=')
  })

  it('only lists tools the caller has scopes for, all read-only', async () => {
    const full = await connect(TOKENS.full)
    const { tools } = await full.listTools()
    expect(tools.map(t => t.name).sort()).toEqual([
      'catalog_quality_summary', 'diagnose_product_visibility', 'find_products_with_issues', 'get_attribute', 'get_category',
      'get_customer_orders', 'get_order', 'get_price', 'get_product', 'get_stock', 'list_categories', 'list_stores',
      'search_orders', 'search_products', 'sync_status',
    ])
    expect(tools.every(t => t.annotations?.readOnlyHint === true)).toBe(true)

    const catalogOnly = await connect(TOKENS.catalog)
    const names = (await catalogOnly.listTools()).tools.map(t => t.name)
    expect(names).not.toContain('get_order')
    expect(names).toContain('search_products')
  })

  it('searches products with store-view names and effective prices', async () => {
    const client = await connect(TOKENS.full)
    const res = await call(client, 'search_products', { query: 'mug' })
    expect(res.total).toBe(1)
    expect(res.items[0]).toMatchObject({ sku: 'MUG', name: 'Enamel Mug (UK)', price: 8, effectivePrice: 6, totalQty: 15, inStock: true })
  })

  it('returns full product detail with labelled attributes, variants and image URLs', async () => {
    const client = await connect(TOKENS.full)
    const p = await call(client, 'get_product', { sku: 'TEE' })
    expect(p.variants.sort()).toEqual(['TEE-M', 'TEE-S'])
    expect(p.attributes).toContainEqual({ code: 'color', label: 'Colour', value: 'Red' })
    expect(p.images[0].url).toBe('https://shop.test/media/catalog/product/t/e/tee.jpg')
    expect(p.categories[0]).toMatchObject({ categoryId: 3, name: 'T-Shirts' })
    expect(p.links).toContainEqual(expect.objectContaining({ linkType: 'crosssell', linkedSku: 'MUG' }))
  })

  it('diagnoses why a product is not visible', async () => {
    const client = await connect(TOKENS.full)
    const hidden = await call(client, 'diagnose_product_visibility', { sku: 'HIDDEN' })
    expect(hidden.likelyVisible).toBe(false)
    const failed = hidden.checks.filter((c: { status: string }) => c.status === 'fail').map((c: { check: string }) => c.check)
    expect(failed).toEqual(expect.arrayContaining(['website', 'categories', 'price']))

    const child = await call(client, 'diagnose_product_visibility', { sku: 'TEE-S' })
    expect(child.checks.find((c: { check: string }) => c.check === 'visibility')).toMatchObject({ status: 'info' })
    expect(child.live).toMatchObject({ salableQty: 5, isSalable: true })
  })

  it('reports catalog quality issues', async () => {
    const client = await connect(TOKENS.full)
    const summary = await call(client, 'catalog_quality_summary')
    const count = (t: string) => summary.issues.find((i: { type: string }) => i.type === t).count
    expect(summary.totalProducts).toBe(5)
    expect(count('missing_images')).toBe(2) // MUG, HIDDEN
    expect(count('missing_description')).toBe(1) // MUG
    expect(count('not_in_website')).toBe(1) // HIDDEN
    expect(count('invalid_price')).toBe(1) // HIDDEN (price 0)
    expect(count('out_of_stock_enabled')).toBe(2) // TEE-M, HIDDEN

    const missing = await call(client, 'find_products_with_issues', { issue: 'missing_required_attributes' })
    expect(missing.items).toEqual([expect.objectContaining({ sku: 'TEE-M', missing_required: ['brand'] })])
  })

  it('combines synced and live stock', async () => {
    const client = await connect(TOKENS.full)
    const stock = await call(client, 'get_stock', { sku: 'MUG' })
    expect(stock).toMatchObject({ totalQty: 15, inStock: true, live: { salableQty: 10, isSalable: true } })
    expect(stock.sources.map((s: { sourceCode: string }) => s.sourceCode)).toEqual(['default', 'leeds'])
  })

  it('reads orders live with PII masked by default', async () => {
    const client = await connect(TOKENS.full)
    const order = await call(client, 'get_order', { order_number: '000000123' })
    expect(order).toMatchObject({
      found: true, status: 'complete', piiMasked: true,
      customer: { name: 'J. S.', email: 'j***@example.com' },
      fulfilment: { invoiced: 'full', shipped: 'full' },
      shipments: [{ tracking: [{ carrier: 'Royal Mail', number: 'RM123456789GB' }] }],
      invoices: [{ state: 'paid' }],
    })
    expect(order.billingAddress).not.toHaveProperty('street')

    const history = await call(client, 'get_customer_orders', { customer_email: 'jane.smith@example.com' })
    expect(history).toMatchObject({ customer: 'j***@example.com', totalOrders: 1 })
  })

  it('shows full customer details only with orders:pii', async () => {
    const client = await connect(TOKENS.pii)
    const order = await call(client, 'get_order', { order_number: '000000123' })
    expect(order.customer.email).toBe('jane.smith@example.com')
    expect(order.billingAddress.street).toEqual(['1 High St'])
  })

  it('audits every tool call with redacted arguments', async () => {
    await new Promise(r => setTimeout(r, 200)) // audit writes are fire-and-forget
    const rows = await handle.db.select().from(auditLog)
    expect(rows.length).toBeGreaterThan(5)
    const lookup = rows.find(r => r.tool === 'get_customer_orders')!
    expect(lookup).toMatchObject({ subject: 'alice', outcome: 'ok', args: { customer_email: 'j***@example.com' } })
  })

  it('maps not-found to a clean tool error', async () => {
    const client = await connect(TOKENS.full)
    const res = await client.callTool({ name: 'get_product', arguments: { sku: 'NOPE' } })
    expect(res.isError).toBe(true)
    expect((res.content as Array<{ text: string }>)[0]!.text).toBe('Product "NOPE" not found')
  })
})

describe('stock not managed in Magento (CATALOG_STOCK_ENABLED=false)', () => {
  it('drops stock checks from visibility diagnosis and quality reports', async () => {
    const noStock = buildApp({ db: handle.db, token: CATALOG_TOKEN, defaultStore: 'default', logger: createLogger('t', 'error'), stockEnabled: false })
    const get = async (url: string) => (await noStock.inject({ url, headers: { authorization: `Bearer ${CATALOG_TOKEN}` } })).json()

    const diag = await get('/products/TEE/visibility')
    expect(diag.checks.map((c: { check: string }) => c.check)).not.toContain('stock')
    expect(diag.checks.find((c: { check: string }) => c.check === 'variants')).toMatchObject({ status: 'pass', detail: '2 of 2 variants enabled.' })

    const summary = await get('/quality/summary')
    expect(summary.issues.map((i: { type: string }) => i.type)).not.toContain('out_of_stock_enabled')
    expect((await noStock.inject({ url: '/quality/issues?type=out_of_stock_enabled', headers: { authorization: `Bearer ${CATALOG_TOKEN}` } })).statusCode).toBe(400)
    await noStock.close()
  })
})

describe('incremental sync and reconciliation', () => {
  it('picks up a product edited in Magento after the last sync', async () => {
    const teeM = data.products.find(p => p.sku === 'TEE-M')!
    teeM.custom_attributes = [...(teeM.custom_attributes as object[]), { attribute_code: 'brand', value: 'Acme' }]
    teeM.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ')

    const inc = await syncProductsIncremental(ctx)
    expect(inc['products:all']).toBe(1)

    const client = await connect(TOKENS.full)
    expect((await call(client, 'find_products_with_issues', { issue: 'missing_required_attributes' })).total).toBe(0)
  })

  it('soft-deletes products removed from Magento on the next full sync', async () => {
    data.products.splice(data.products.findIndex(p => p.sku === 'HIDDEN'), 1)
    const full = await syncProductsFull(ctx)
    expect(full.softDeleted).toBe(1)

    const client = await connect(TOKENS.full)
    expect((await call(client, 'search_products', { query: 'lamp' })).total).toBe(0)
  })
})

describe('ChatGPT-style sign-in with Magento admin credentials (AUTH_MODE=magento)', () => {
  const REDIRECT = 'https://chatgpt.com/connector_platform_oauth_redirect'
  const verifier = crypto.randomBytes(32).toString('base64url')
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
  let clientId: string
  let meta: { authorization_endpoint: string; token_endpoint: string; registration_endpoint: string }

  const hiddenRequest = (html: string) => /name="request" value="([^"]+)"/.exec(html)![1]!.replace(/&amp;/g, '&')

  async function startLogin() {
    const u = new URL(meta.authorization_endpoint)
    Object.entries({
      response_type: 'code', client_id: clientId, redirect_uri: REDIRECT, code_challenge: challenge, code_challenge_method: 'S256',
      state: 'xyz', resource: `${authBase}/mcp`,
    }).forEach(([k, v]) => u.searchParams.set(k, v))
    const res = await fetch(u, { redirect: 'manual' })
    expect(res.status).toBe(200)
    return hiddenRequest(await res.text())
  }

  const postLogin = (request: string, username: string, password: string, otp = ADMIN_OTP) =>
    fetch(`${authBase}/login`, { method: 'POST', redirect: 'manual', body: new URLSearchParams({ request, username, password, otp }) })

  async function token(params: Record<string, string>) {
    const res = await fetch(meta.token_endpoint, { method: 'POST', body: new URLSearchParams({ client_id: clientId, ...params }) })
    return { status: res.status, body: (await res.json()) as Record<string, string> }
  }

  it('advertises itself as the authorization server and registers ChatGPT dynamically', async () => {
    const prm = await (await fetch(`${authBase}/.well-known/oauth-protected-resource/mcp`)).json()
    expect(prm).toMatchObject({ resource: `${authBase}/mcp`, authorization_servers: [`${authBase}/`] })
    meta = await (await fetch(`${authBase}/.well-known/oauth-authorization-server`)).json()
    expect(meta).toMatchObject({ code_challenge_methods_supported: ['S256'] })

    const reg = await fetch(meta.registration_endpoint, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ client_name: 'ChatGPT', redirect_uris: [REDIRECT], token_endpoint_auth_method: 'none', grant_types: ['authorization_code', 'refresh_token'] }),
    })
    expect(reg.status).toBe(201)
    clientId = ((await reg.json()) as { client_id: string }).client_id
  })

  it('shows a login page and rejects a wrong password without leaking why', async () => {
    const request = await startLogin()
    const bad = await postLogin(request, 'alice', 'wrong')
    expect(bad.status).toBe(401)
    const html = await bad.text()
    expect(html).toContain('Sign-in failed')
    expect(html).not.toContain('wrong')
  })

  it('rejects a tampered login request', async () => {
    const request = await startLogin()
    const tampered = request.replace(/^[^.]+/, Buffer.from(JSON.stringify({ clientId, redirectUri: 'https://evil.example/cb', exp: Date.now() + 60_000 })).toString('base64url'))
    expect((await postLogin(tampered, 'alice', ADMIN_USERS.alice!)).status).toBe(400)
  })

  it('issues tokens after a valid Magento login + 2FA, scoped by the allowlist', async () => {
    const res = await postLogin(await startLogin(), 'Alice', ADMIN_USERS.alice!)
    expect(res.status).toBe(302)
    const location = new URL(res.headers.get('location')!)
    expect(`${location.origin}${location.pathname}`).toBe(REDIRECT)
    expect(location.searchParams.get('state')).toBe('xyz')
    const code = location.searchParams.get('code')!

    const t = await token({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: REDIRECT, resource: `${authBase}/mcp` })
    expect(t.status).toBe(200)
    expect(t.body).toMatchObject({ token_type: 'Bearer', expires_in: 3600, scope: 'catalog:read orders:read orders:pii' })

    // Codes are single use.
    expect((await token({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: REDIRECT })).status).toBe(400)

    const client = new Client({ name: 'chatgpt-sim', version: '0' })
    await client.connect(new StreamableHTTPClientTransport(new URL(`${authBase}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${t.body.access_token}` } } }))
    const order = await call(client, 'get_order', { order_number: '000000123' })
    expect(order.customer.email).toBe('jane.smith@example.com') // alice has orders:pii

    // Refresh rotates: new pair works, old refresh token is dead.
    const r = await token({ grant_type: 'refresh_token', refresh_token: t.body.refresh_token! })
    expect(r.status).toBe(200)
    expect(r.body.access_token).not.toBe(t.body.access_token)
    expect((await token({ grant_type: 'refresh_token', refresh_token: t.body.refresh_token! })).status).toBe(400)
  })

  it('wrong PKCE verifier is rejected', async () => {
    const res = await postLogin(await startLogin(), 'alice', ADMIN_USERS.alice!)
    const code = new URL(res.headers.get('location')!).searchParams.get('code')!
    expect((await token({ grant_type: 'authorization_code', code, code_verifier: 'x'.repeat(43), redirect_uri: REDIRECT })).status).toBe(400)
  })

  it('gives other admins only the default scopes, and revocation cuts them off immediately', async () => {
    const res = await postLogin(await startLogin(), 'bob', ADMIN_USERS.bob!)
    const code = new URL(res.headers.get('location')!).searchParams.get('code')!
    const t = await token({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: REDIRECT })
    expect(t.body.scope).toBe('catalog:read')

    const client = new Client({ name: 'chatgpt-sim', version: '0' })
    await client.connect(new StreamableHTTPClientTransport(new URL(`${authBase}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${t.body.access_token}` } } }))
    expect((await client.listTools()).tools.map(x => x.name)).not.toContain('get_order')

    const { revokeOAuthSubject } = await import('@znet/catalog-db')
    expect(await revokeOAuthSubject(handle.db, 'bob')).toBe(2)
    const after = await fetch(`${authBase}/mcp`, {
      method: 'POST',
      headers: { authorization: `Bearer ${t.body.access_token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
    })
    expect(after.status).toBe(401)
  })

  it('locks out repeated failures', async () => {
    const request = await startLogin()
    for (let i = 0; i < 5; i++) await postLogin(request, 'carol', 'nope')
    const res = await postLogin(request, 'carol', 'nope')
    expect(res.status).toBe(429)
  })
})
