import { DEVFRAME_RPC_DUMP_DIRNAME } from 'devframe/constants'
import { hash } from 'devframe/utils/hash'
import { structuredCloneStringify } from 'devframe/utils/structured-clone'
import { describe, expect, it } from 'vitest'
import { createStaticRpcCaller } from './static-rpc'

const DEMO_STATIC_VERSION_PATH = `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~version.static.json`
const DEMO_QUERY_BASE_PATH = `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~get-item`
const DEMO_QUERY_RECORDS_PATH = `${DEMO_QUERY_BASE_PATH}.record`
const DEMO_QUERY_FALLBACK_PATH = `${DEMO_QUERY_BASE_PATH}.fallback.json`

describe('createStaticRpcCaller', () => {
  it('loads static rpc shards lazily and caches by file path', async () => {
    const calls: string[] = []
    const caller = createStaticRpcCaller(
      {
        'demo:version': {
          type: 'static',
          path: DEMO_STATIC_VERSION_PATH,
        },
      },
      async (path) => {
        calls.push(path)
        return { output: '1.0.0' }
      },
    )

    await expect(caller.call('demo:version', [])).resolves.toBe('1.0.0')
    await expect(caller.call('demo:version', [])).resolves.toBe('1.0.0')
    expect(calls).toEqual([DEMO_STATIC_VERSION_PATH])
  })

  it('resolves query records, supports fallback, and replays dumped errors', async () => {
    const caller = createStaticRpcCaller(
      {
        'demo:get-item': {
          type: 'query',
          records: {
            [hash(['a'])]: `${DEMO_QUERY_RECORDS_PATH}.ok.json`,
            [hash(['boom'])]: `${DEMO_QUERY_RECORDS_PATH}.error.json`,
          },
          fallback: DEMO_QUERY_FALLBACK_PATH,
        },
      },
      async (path) => {
        if (path.endsWith('.ok.json')) {
          return {
            inputs: ['a'],
            output: { id: 'a' },
          }
        }
        if (path.endsWith('.error.json')) {
          return {
            inputs: ['boom'],
            error: {
              name: 'TypeError',
              message: 'boom',
            },
          }
        }
        if (path.endsWith('.fallback.json')) {
          return {
            inputs: [],
            output: null,
          }
        }
        throw new Error(`Unexpected path: ${path}`)
      },
    )

    await expect(caller.call('demo:get-item', ['a'])).resolves.toEqual({ id: 'a' })
    await expect(caller.call('demo:get-item', ['missing'])).resolves.toBeNull()
    await expect(caller.call('demo:get-item', ['boom'])).rejects.toThrow('boom')
  })

  it('keeps call strict while optional/event calls are soft for missing methods', async () => {
    const caller = createStaticRpcCaller({}, async () => {
      throw new Error('Should not fetch')
    })

    await expect(caller.callOptional('demo:missing', [])).resolves.toBeUndefined()
    await expect(caller.callEvent('demo:missing', [])).resolves.toBeUndefined()
    await expect(caller.call('demo:missing', [])).rejects.toThrow('[devframe-rpc] Function "demo:missing" not found in dump store')
  })

  it('treats callEvent as no-op in static mode even for known methods', async () => {
    const caller = createStaticRpcCaller(
      {
        'demo:version': {
          type: 'static',
          path: DEMO_STATIC_VERSION_PATH,
        },
      },
      async () => {
        throw new Error('Should not fetch')
      },
    )

    await expect(caller.callEvent('demo:version', [])).resolves.toBeUndefined()
  })

  it('supports legacy inline manifest values for backward compatibility', async () => {
    const caller = createStaticRpcCaller(
      {
        'demo:legacy': { ok: true },
      },
      async () => {
        throw new Error('Should not fetch')
      },
    )

    await expect(caller.call('demo:legacy', [])).resolves.toEqual({ ok: true })
  })

  it('revives structured-clone-tagged static entries (preserves Map)', async () => {
    const caller = createStaticRpcCaller(
      {
        'demo:graph': {
          type: 'static',
          path: `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~graph.static.json`,
          serialization: 'structured-clone',
        },
      },
      async () => {
        // What a server would have written: SC-stringified, then read
        // back via fetch.json() (i.e. JSON.parse of the SC text).
        const payload = { output: new Map([['a', 1], ['b', 2]]) }
        return JSON.parse(structuredCloneStringify(payload))
      },
    )

    const result = await caller.call('demo:graph', []) as Map<string, number>
    expect(result).toBeInstanceOf(Map)
    expect(result.get('a')).toBe(1)
    expect(result.get('b')).toBe(2)
  })

  it('revives structured-clone-tagged query records (preserves Set)', async () => {
    const recordPath = `${DEMO_QUERY_BASE_PATH}.record.${hash(['k'])}.json`
    const caller = createStaticRpcCaller(
      {
        'demo:query-set': {
          type: 'query',
          serialization: 'structured-clone',
          records: { [hash(['k'])]: recordPath },
        },
      },
      async () => {
        const payload = { inputs: ['k'], output: new Set(['x', 'y']) }
        return JSON.parse(structuredCloneStringify(payload))
      },
    )

    const result = await caller.call('demo:query-set', ['k']) as Set<string>
    expect(result).toBeInstanceOf(Set)
    expect(result.has('x')).toBe(true)
  })

  it('treats untagged manifest entries as JSON (back-compat)', async () => {
    const caller = createStaticRpcCaller(
      {
        'demo:legacy-static': {
          type: 'static',
          path: `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~legacy.static.json`,
          // no `serialization` field — must default to JSON parsing
        },
      },
      async () => ({ output: { items: [1, 2, 3] } }),
    )

    await expect(caller.call('demo:legacy-static', [])).resolves.toEqual({ items: [1, 2, 3] })
  })

  it('treats placeholder args on static entries as a no-arg call', async () => {
    const caller = createStaticRpcCaller(
      {
        'demo:messages': {
          type: 'static',
          path: DEMO_STATIC_VERSION_PATH,
        },
      },
      async () => ({ output: ['ok'] }),
    )

    await expect(caller.call('demo:messages', [null])).resolves.toEqual(['ok'])
    await expect(caller.call('demo:messages', [undefined])).resolves.toEqual(['ok'])
    await expect(caller.call('demo:messages', ['real'])).rejects.toThrow('No dump match')
  })

  it('treats placeholder args on legacy inline entries as a no-arg call', async () => {
    const caller = createStaticRpcCaller(
      {
        'demo:legacy': { ok: true },
      },
      async () => {
        throw new Error('Should not fetch')
      },
    )

    await expect(caller.call('demo:legacy', [null])).resolves.toEqual({ ok: true })
    await expect(caller.call('demo:legacy', ['real'])).rejects.toThrow('No dump match')
  })

  it('unwraps enveloped static files written as the full StaticRpcDumpFile', async () => {
    const caller = createStaticRpcCaller(
      {
        'demo:graph': {
          type: 'static',
          path: `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~graph.static.json`,
          serialization: 'structured-clone',
        },
      },
      async () => ({
        serialization: 'structured-clone',
        fnName: 'demo:graph',
        data: JSON.parse(structuredCloneStringify({ output: new Map([['a', 1]]) })),
      }),
    )

    const result = await caller.call('demo:graph', []) as Map<string, number>
    expect(result).toBeInstanceOf(Map)
    expect(result.get('a')).toBe(1)
  })

  it('unwraps enveloped query records written as the full StaticRpcDumpFile', async () => {
    const recordPath = `${DEMO_QUERY_BASE_PATH}.record.${hash(['k'])}.json`
    const caller = createStaticRpcCaller(
      {
        'demo:query-set': {
          type: 'query',
          serialization: 'structured-clone',
          records: { [hash(['k'])]: recordPath },
        },
      },
      async () => ({
        serialization: 'structured-clone',
        fnName: 'demo:query-set',
        data: JSON.parse(structuredCloneStringify({ inputs: ['k'], output: new Set(['x']) })),
      }),
    )

    const result = await caller.call('demo:query-set', ['k']) as Set<string>
    expect(result).toBeInstanceOf(Set)
    expect(result.has('x')).toBe(true)
  })
})
