/**
 * Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
 *
 * These prove that a Cloudflare Worker (Edge) can sit in front of a
 * TypeScript backend and behave identically to sitting in front of Django.
 */

import { describe, test, expect, beforeEach } from 'bun:test'
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'

const UserCtx = new ReactContext('user')

function setupUserContext() {
    const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
        return { name: `user_${userId}`, email: `user${userId}@test.com` }
    })

    const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
        return { count: userId * 10 }
    })

    const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
        return { name, email: `user${userId}@test.com` }
    })

    client({ affects: 'userProfile' }, async function updateName(userId: number, name: string) {
        return { name, email: `user${userId}@test.com` }
    })
}

describe('Edge Compatibility', () => {
    beforeEach(() => {
        clearRegistry()
        setupUserContext()
    })

    // ── Deterministic JSON ──────────────────────────────────────────────

    test('deterministic JSON output', async () => {
        const r1 = await handleContextFetch('user', { userId: '5' })
        const r2 = await handleContextFetch('user', { userId: '5' })
        expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body))
    })

    test('different params produce different responses', async () => {
        const r1 = await handleContextFetch('user', { userId: '5' })
        const r2 = await handleContextFetch('user', { userId: '6' })
        expect(JSON.stringify(r1.body)).not.toBe(JSON.stringify(r2.body))
        expect(r1.body.userProfile.name).toBe('user_5')
        expect(r2.body.userProfile.name).toBe('user_6')
    })

    // ── Cache-Control correctness ───────────────────────────────────────

    test('context GET emits no-store', async () => {
        const r = await handleContextFetch('user', { userId: '5' })
        expect(r.headers['Cache-Control']).toBe('no-store')
    })

    test('mutation POST not cacheable', async () => {
        const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
        expect(r.headers['Cache-Control']).toBe('no-store')
    })

    test('error response not cacheable', async () => {
        const r = await handleContextFetch('nonexistent', {})
        expect(r.status).toBe(404)
        expect(r.headers['Cache-Control']).toBe('no-store')
    })

    // ── X-Mizan-Invalidate header ──────────────────────────────────────

    test('mutation response includes invalidation header', async () => {
        const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
        expect(r.headers['X-Mizan-Invalidate']).toBeDefined()
        expect(r.headers['X-Mizan-Invalidate']).toContain('user')
    })

    test('auto-scoped invalidation in header', async () => {
        const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
        expect(r.headers['X-Mizan-Invalidate']).toBe('user;userId=5')
    })

    test('invalidation header matches JSON body', async () => {
        const r = await handleMutationCall('updateProfile', { userId: 5, name: 'X' })
        const body = r.body
        expect(body.invalidate[0].context).toBe('user')
        expect(body.invalidate[0].params.userId).toBe(5)
        expect(r.headers['X-Mizan-Invalidate']).toContain('user;userId=5')
    })

    test('function-level invalidation in header', async () => {
        const r = await handleMutationCall('updateName', { userId: 7, name: 'X' })
        expect(r.headers['X-Mizan-Invalidate']).toContain('userProfile')
    })

    test('no invalidation header on context GET', async () => {
        const r = await handleContextFetch('user', { userId: '5' })
        expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
    })

    // ── Header format edge cases ───────────────────────────────────────

    test('special characters in param values are URL-encoded', () => {
        const header = formatInvalidateHeader([
            { context: 'search', params: { q: 'a;b' } },
        ])
        expect(header).not.toContain(';b')
        expect(header).toContain('a%3Bb')
    })

    test('spaces in param values are URL-encoded', () => {
        const header = formatInvalidateHeader([
            { context: 'search', params: { q: 'hello world' } },
        ])
        expect(header).not.toContain(' ')
        expect(header).toContain('hello%20world')
    })

    test('header round-trip with special chars', () => {
        const header = formatInvalidateHeader([
            { context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
        ])

        // Parse (what Edge does)
        const segments = header.split(';')
        const ctx = segments[0]
        const params: Record<string, string> = {}
        for (const seg of segments.slice(1)) {
            const [k, v] = seg.split('=', 2)
            params[decodeURIComponent(k)] = decodeURIComponent(v)
        }

        expect(ctx).toBe('data')
        expect(params.name).toBe("O'Brien")
        expect(params.tag).toBe('a;b;c')
    })

    // ── Empty invalidation ─────────────────────────────────────────────

    test('no affects = no header, no body key', async () => {
        client({ context: new ReactContext('plain') }, async function plainFn() {
            return { ok: true }
        })

        // A context function called via mutation dispatch (shouldn't have invalidation)
        // Actually test a function without affects
        clearRegistry()
        client({}, async function noAffects() { return { ok: true } })
        const r = await handleMutationCall('noAffects', {})
        expect(r.body.invalidate).toBeUndefined()
        expect(r.headers['X-Mizan-Invalidate']).toBeUndefined()
    })

    // ── Private functions ──────────────────────────────────────────────

    test('private functions rejected from RPC', async () => {
        clearRegistry()
        client({ affects: 'subscription', private: true }, async function webhook() {
            return { ok: true }
        })
        const r = await handleMutationCall('webhook', {})
        expect(r.status).toBe(403)
    })

    // ── Unknown function ───────────────────────────────────────────────

    test('unknown function returns 404', async () => {
        const r = await handleMutationCall('doesNotExist', {})
        expect(r.status).toBe(404)
    })

    test('unknown context returns 404', async () => {
        const r = await handleContextFetch('doesNotExist', {})
        expect(r.status).toBe(404)
    })
})

describe('Manifest', () => {
    beforeEach(() => {
        clearRegistry()
        setupUserContext()
    })

    test('manifest matches expected structure', () => {
        const m = generateManifest()

        expect(m.version).toBe(1)
        expect(m.contexts.user).toBeDefined()
        expect(m.contexts.user.endpoints).toEqual(['/api/mizan/ctx/user/'])
        expect(m.contexts.user.params).toContain('userId')
        expect(m.contexts.user.user_scoped).toBe(true)
        expect(m.contexts.user.render_strategy).toBe('dynamic_cached')
    })

    test('mutations section includes auto-scoped params', () => {
        const m = generateManifest()

        expect(m.mutations.updateProfile).toBeDefined()
        expect(m.mutations.updateProfile.affects).toEqual(['user'])
        expect(m.mutations.updateProfile.auto_scoped_params).toContain('userId')
    })

    test('PSR strategy for non-user-scoped context', () => {
        clearRegistry()
        const ProductCtx = new ReactContext('products')
        client({ context: ProductCtx }, async function productDetail(productId: number) {
            return { id: productId }
        })

        const m = generateManifest()
        expect(m.contexts.products.user_scoped).toBe(false)
        expect(m.contexts.products.render_strategy).toBe('psr')
    })

    test('private mutation in manifest', () => {
        clearRegistry()
        client(
            { affects: 'subscription', private: true, route: '/webhooks/stripe/', methods: ['POST'] },
            async function stripeWebhook() { return new Response('ok') },
        )

        const m = generateManifest()
        expect(m.mutations.stripeWebhook).toBeDefined()
        expect(m.mutations.stripeWebhook.private).toBe(true)
        expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
        expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
    })

    test('rev appears in manifest', () => {
        clearRegistry()
        const Ctx = new ReactContext('data')
        client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) {
            return { value: itemId }
        })

        const m = generateManifest()
        const fn = m.contexts.data.functions[0]
        expect(fn.rev).toBe(3)
    })

    test('cache TTL appears in manifest', () => {
        clearRegistry()
        const Ctx = new ReactContext('trending')
        client({ context: Ctx, cache: 60 }, async function trendingFn() {
            return { items: [] }
        })

        const m = generateManifest()
        const fn = m.contexts.trending.functions[0]
        expect(fn.cache).toBe(60)
    })

    test('cache=60 still emits no-store on HTTP', async () => {
        clearRegistry()
        const Ctx = new ReactContext('live')
        client({ context: Ctx, cache: 60 }, async function liveFn() {
            return { score: 42 }
        })

        const r = await handleContextFetch('live', {})
        expect(r.headers['Cache-Control']).toBe('no-store')
    })

    test('cache=false sets no-store', async () => {
        clearRegistry()
        const Ctx = new ReactContext('random')
        client({ context: Ctx, cache: false }, async function randomFn() {
            return { value: Math.random() }
        })

        const r = await handleContextFetch('random', {})
        expect(r.headers['Cache-Control']).toBe('no-store')
    })
})

// ── Cache Conformance Tests ────────────────────────────────────────────

describe('Cache Conformance', () => {
    const SECRET = 'test-pin-secret-that-is-32bytes!'

    test('deriveCacheKey determinism', () => {
        const k1 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
        const k2 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
        expect(k1).toBe(k2)
        expect(k1).toStartWith('ctx:user:')
        expect(k1).toHaveLength('ctx:user:'.length + 64)
    })

    test('deriveCacheKey param order irrelevant', () => {
        const k1 = deriveCacheKey(SECRET, 'ctx', { a: '1', b: '2' })
        const k2 = deriveCacheKey(SECRET, 'ctx', { b: '2', a: '1' })
        expect(k1).toBe(k2)
    })

    test('deriveCacheKey cross-language pin (matches Python)', () => {
        // These exact values are pinned from Python's derive_cache_key output.
        // If this test fails, cross-language cache key compatibility is broken.
        const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
        expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')

        const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
        expect(userScopedKey).toBe('ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
    })

    test('MemoryCache get/set/clear', () => {
        const cache = new MemoryCache()
        expect(cache.get('k1')).toBeNull()

        cache.set('k1', '{"data":true}')
        expect(cache.get('k1')).toBe('{"data":true}')

        cache.clear()
        expect(cache.get('k1')).toBeNull()
    })

    test('scoped purge recomputes key directly', () => {
        const cache = new MemoryCache()
        cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
        cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')

        const count = cachePurge(cache, 'user', { user_id: '5' }, SECRET)
        expect(count).toBe(1)

        expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
        expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).not.toBeNull()
    })

    test('broad purge removes all entries', () => {
        const cache = new MemoryCache()
        cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
        cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')

        const count = cachePurge(cache, 'user')
        expect(count).toBe(2)

        expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
        expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).toBeNull()
    })

    test('handleContextFetch caches response', async () => {
        clearRegistry()
        const Ctx = new ReactContext('cached')
        client({ context: Ctx }, async function cachedFn(itemId: number) {
            return { value: itemId }
        })

        const cache = new MemoryCache()
        setCache(cache)
        setCacheSecret(SECRET)

        const r1 = await handleContextFetch('cached', { itemId: '1' })
        expect(r1.status).toBe(200)
        expect(r1.headers['X-Mizan-Cache']).toBe('MISS')

        const r2 = await handleContextFetch('cached', { itemId: '1' })
        expect(r2.status).toBe(200)
        expect(r2.headers['X-Mizan-Cache']).toBe('HIT')
        expect(r2.body).toEqual(r1.body)

        resetCache()
        setCacheSecret(null)
    })

    test('handleMutationCall purges cache', async () => {
        clearRegistry()
        const Ctx = new ReactContext('product')
        client({ context: Ctx }, async function getProduct(productId: number) {
            return { id: productId }
        })
        client({ affects: Ctx }, async function updateProduct(productId: number, name: string) {
            return { ok: true }
        })

        const cache = new MemoryCache()
        setCache(cache)
        setCacheSecret(SECRET)

        // Prime cache
        await handleContextFetch('product', { productId: '1' })

        // Mutate
        await handleMutationCall('updateProduct', { productId: 1, name: 'New' })

        // Cache should be purged — next fetch is MISS
        const r = await handleContextFetch('product', { productId: '1' })
        expect(r.headers['X-Mizan-Cache']).toBe('MISS')

        resetCache()
        setCacheSecret(null)
    })

    test('scoped invalidation preserves other entries', async () => {
        clearRegistry()
        const Ctx = new ReactContext('user')
        client({ context: Ctx }, async function userProfile(userId: number) {
            return { name: `user_${userId}` }
        })
        client({ affects: Ctx }, async function editUser(userId: number, name: string) {
            return { ok: true }
        })

        const cache = new MemoryCache()
        setCache(cache)
        setCacheSecret(SECRET)

        // Prime both users
        await handleContextFetch('user', { userId: '5' })
        await handleContextFetch('user', { userId: '6' })

        // Mutate only user 5
        await handleMutationCall('editUser', { userId: 5, name: 'New' })

        // User 6 should still be cached
        const r6 = await handleContextFetch('user', { userId: '6' })
        expect(r6.headers['X-Mizan-Cache']).toBe('HIT')

        // User 5 should be a miss
        const r5 = await handleContextFetch('user', { userId: '5' })
        expect(r5.headers['X-Mizan-Cache']).toBe('MISS')

        resetCache()
        setCacheSecret(null)
    })
})
