import { beforeEach, describe, expect, test, vi } from 'vitest'
import { Hono } from 'hono'
import { installOnError } from './helpers/install-on-error.js'

/**
 * R-32 Phase B tests — public feedback list + vote endpoints + admin PATCH.
 *
 * The supabaseAdmin client is fully mocked through `lib/db.js`. We hand each
 * test a `dbResponses` map keyed by `<verb>:<table>` so the chainable builder
 * returned by `from()` can answer with whatever the test expects. That keeps
 * the tests narrow (single round-trip per assertion) without needing a real
 * Supabase instance, while still exercising the router's auth + validation +
 * error-envelope wiring through the global onError handler.
 *
 * The authJwt middleware is mocked at module level so every request looks
 * like a logged-in user (and, for the admin tests, like a system admin).
 */

interface ChainResult {
  data?: unknown
  error?: unknown
}

let dbResponses: Record<string, ChainResult> = {}

vi.mock('../lib/db.js', () => {
  // Generic chainable builder. Every call returns `this` so any combination
  // of .select().eq().eq().in().order().limit().maybeSingle() flows back to
  // the per-test response map. The final await resolves to dbResponses[key].
  function makeBuilder(verb: string, table: string) {
    const key = `${verb}:${table}`
    const builder: Record<string, unknown> = {}
    const passThrough = () => builder
    const methods = [
      'select',
      'eq',
      'neq',
      'in',
      'is',
      'order',
      'limit',
      'maybeSingle',
      'single',
      'update',
    ]
    for (const m of methods) builder[m] = passThrough
    // Make the builder thenable so `await query` resolves.
    builder.then = (
      resolve: (value: ChainResult) => unknown,
      reject?: (reason: unknown) => unknown,
    ) => {
      const response = dbResponses[key] ?? { data: null, error: null }
      try {
        return Promise.resolve(resolve(response))
      } catch (err) {
        return reject ? Promise.resolve(reject(err)) : Promise.reject(err)
      }
    }
    return builder
  }

  return {
    supabaseAdmin: {
      from: (table: string) => {
        // The verb is established by which final method the caller chains
        // (.update / .insert / .delete / .select). We disambiguate by
        // wrapping each kicker so each returns a builder with the right key.
        return {
          select: (...args: unknown[]) => {
            const b = makeBuilder('select', table)
            const real = b.select as (...a: unknown[]) => unknown
            return real(...args)
          },
          insert: (rows: unknown) => {
            // INSERT returns a builder too (so callers can .select() chain),
            // but we treat the bare insert as an awaitable that resolves
            // to `insert:<table>`.
            const b = makeBuilder('insert', table)
            // Stash the inserted rows so tests can assert on payload shape
            // via dbResponses[key].data setting.
            ;(b as Record<string, unknown>)._rows = rows
            return b
          },
          update: (patch: unknown) => {
            const b = makeBuilder('update', table)
            ;(b as Record<string, unknown>)._patch = patch
            return b
          },
          delete: () => makeBuilder('delete', table),
        }
      },
    },
    supabaseClient: {
      auth: {
        getUser: vi.fn(async () => ({
          data: { user: { id: 'admin-user', email: 'admin@spanlens.io' } },
          error: null,
        })),
      },
    },
  }
})

vi.mock('../lib/resend.js', () => ({
  sendEmail: vi.fn(async () => undefined),
}))

vi.mock('../lib/wait-until.js', () => ({
  fireAndForget: vi.fn(),
}))

// authJwt: write endpoints (POST submit, vote, un-vote, admin PATCH) come
// through as admin-user (matches the supabaseClient.auth.getUser mock above).
// The public GET handler now resolves its userId from the Bearer token via
// supabaseClient.auth.getUser, so tests that need has_voted=true must send
// `Authorization: Bearer <anything>` and rely on the same getUser mock.
vi.mock('../middleware/authJwt.js', () => ({
  authJwt: async (
    c: {
      set: (k: string, v: unknown) => void
      get: (k: string) => unknown
    },
    next: () => Promise<unknown>,
  ) => {
    c.set('userId', 'admin-user')
    c.set('orgId', 'org-1')
    c.set('email', 'admin@spanlens.io')
    return next()
  },
}))

let feedbackRouter: typeof import('../api/feedback.js').feedbackRouter
let adminFeedbackRouter: typeof import('../api/admin/feedback.js').adminFeedbackRouter

beforeEach(async () => {
  vi.resetModules()
  dbResponses = {}
  process.env['SPANLENS_ADMIN_EMAILS'] = 'admin@spanlens.io'
  ;({ feedbackRouter } = await import('../api/feedback.js'))
  ;({ adminFeedbackRouter } = await import('../api/admin/feedback.js'))
})

function makePublicApp() {
  const app = new Hono()
  app.route('/feedback', feedbackRouter)
  installOnError(app)
  return app
}

function makeAdminApp() {
  const app = new Hono()
  app.route('/admin/feedback', adminFeedbackRouter)
  installOnError(app)
  return app
}

// ─── GET /feedback ─────────────────────────────────────────────────────────

describe('GET /feedback — public roadmap', () => {
  test('returns rows with vote_count + has_voted joined', async () => {
    dbResponses['select:feedback'] = {
      data: [
        {
          id: 'f-1',
          message: 'Add dark mode',
          category: 'feature',
          status: 'planned',
          response_message: null,
          changelog_url: null,
          responded_at: null,
          created_at: '2026-06-09T00:00:00Z',
          feedback_votes: [{ count: 5 }],
        },
        {
          id: 'f-2',
          message: 'Fix login bug',
          category: 'bug',
          status: 'new',
          response_message: null,
          changelog_url: null,
          responded_at: null,
          created_at: '2026-06-08T00:00:00Z',
          feedback_votes: [{ count: 12 }],
        },
      ],
      error: null,
    }
    dbResponses['select:feedback_votes'] = {
      data: [{ feedback_id: 'f-2' }],
      error: null,
    }

    // Authorization header is required for has_voted resolution — the public
    // GET handler is anonymous-safe but only populates has_voted when a valid
    // Bearer token surfaces a userId via supabaseClient.auth.getUser (mocked
    // above to return admin-user). Without the header has_voted would be
    // false for every row.
    const res = await makePublicApp().request('/feedback', {
      headers: { Authorization: 'Bearer fake' },
    })
    expect(res.status).toBe(200)
    const body = (await res.json()) as {
      success: boolean
      data: Array<{ id: string; vote_count: number; has_voted: boolean }>
    }
    expect(body.success).toBe(true)
    // Sorted by vote_count DESC — f-2 (12) first, then f-1 (5).
    expect(body.data.map((d) => d.id)).toEqual(['f-2', 'f-1'])
    expect(body.data[0]?.vote_count).toBe(12)
    expect(body.data[0]?.has_voted).toBe(true)
    expect(body.data[1]?.has_voted).toBe(false)
  })

  test('anonymous caller (no Authorization) → list returns but has_voted=false', async () => {
    dbResponses['select:feedback'] = {
      data: [
        {
          id: 'f-1',
          message: 'Anonymous-visible item',
          category: 'feature',
          status: 'planned',
          response_message: null,
          changelog_url: null,
          responded_at: null,
          created_at: '2026-06-09T00:00:00Z',
          feedback_votes: [{ count: 3 }],
        },
      ],
      error: null,
    }
    // No `select:feedback_votes` configured — anonymous path must skip that
    // query entirely. Test ensures we don't 500 by trying to dereference an
    // absent vote-set.

    const res = await makePublicApp().request('/feedback')
    expect(res.status).toBe(200)
    const body = (await res.json()) as {
      data: Array<{ id: string; vote_count: number; has_voted: boolean }>
    }
    expect(body.data[0]?.vote_count).toBe(3)
    expect(body.data[0]?.has_voted).toBe(false)
  })

  test('rejects invalid status filter with VALIDATION_FAILED envelope', async () => {
    const res = await makePublicApp().request('/feedback?status=lol')
    expect(res.status).toBe(400)
    const body = (await res.json()) as { error: { code: string } }
    expect(body.error.code).toBe('VALIDATION_FAILED')
  })

  test('empty page → no follow-up votes query, returns empty array', async () => {
    dbResponses['select:feedback'] = { data: [], error: null }
    const res = await makePublicApp().request('/feedback')
    expect(res.status).toBe(200)
    const body = (await res.json()) as { data: unknown[] }
    expect(body.data).toEqual([])
  })

  test('DB error surfaces as INTERNAL_ERROR', async () => {
    dbResponses['select:feedback'] = {
      data: null,
      error: { message: 'connection refused' },
    }
    const res = await makePublicApp().request('/feedback')
    expect(res.status).toBe(500)
    const body = (await res.json()) as { error: { code: string } }
    expect(body.error.code).toBe('INTERNAL_ERROR')
  })
})

// ─── POST /feedback/:id/vote ───────────────────────────────────────────────

describe('POST /feedback/:id/vote — upvote', () => {
  test('existing feedback → 200 success', async () => {
    dbResponses['select:feedback'] = { data: { id: 'f-1' }, error: null }
    dbResponses['insert:feedback_votes'] = { data: null, error: null }

    const res = await makePublicApp().request('/feedback/f-1/vote', { method: 'POST' })
    expect(res.status).toBe(200)
  })

  test('missing feedback → NOT_FOUND', async () => {
    dbResponses['select:feedback'] = { data: null, error: null }

    const res = await makePublicApp().request('/feedback/missing/vote', { method: 'POST' })
    expect(res.status).toBe(404)
    const body = (await res.json()) as { error: { code: string } }
    expect(body.error.code).toBe('NOT_FOUND')
  })

  test('duplicate vote (Postgres 23505) → still 200 (idempotent)', async () => {
    dbResponses['select:feedback'] = { data: { id: 'f-1' }, error: null }
    dbResponses['insert:feedback_votes'] = {
      data: null,
      error: { code: '23505', message: 'duplicate key' },
    }

    const res = await makePublicApp().request('/feedback/f-1/vote', { method: 'POST' })
    expect(res.status).toBe(200)
  })

  test('other insert error → INTERNAL_ERROR', async () => {
    dbResponses['select:feedback'] = { data: { id: 'f-1' }, error: null }
    dbResponses['insert:feedback_votes'] = {
      data: null,
      error: { code: 'XX000', message: 'something broke' },
    }

    const res = await makePublicApp().request('/feedback/f-1/vote', { method: 'POST' })
    expect(res.status).toBe(500)
    const body = (await res.json()) as { error: { code: string } }
    expect(body.error.code).toBe('INTERNAL_ERROR')
  })
})

// ─── DELETE /feedback/:id/vote ─────────────────────────────────────────────

describe('DELETE /feedback/:id/vote — un-vote', () => {
  test('always returns 200 (idempotent — no 404 on never-voted)', async () => {
    dbResponses['delete:feedback_votes'] = { data: null, error: null }
    const res = await makePublicApp().request('/feedback/f-1/vote', { method: 'DELETE' })
    expect(res.status).toBe(200)
  })

  test('DB error → INTERNAL_ERROR', async () => {
    dbResponses['delete:feedback_votes'] = {
      data: null,
      error: { message: 'boom' },
    }
    const res = await makePublicApp().request('/feedback/f-1/vote', { method: 'DELETE' })
    expect(res.status).toBe(500)
  })
})

// ─── PATCH /admin/feedback/:id ─────────────────────────────────────────────

describe('PATCH /admin/feedback/:id — admin response surface', () => {
  test('status only → 200, updates status', async () => {
    dbResponses['update:feedback'] = {
      data: {
        id: 'f-1',
        status: 'planned',
        response_message: null,
        changelog_url: null,
        responded_at: null,
        responded_by: null,
      },
      error: null,
    }
    const res = await makeAdminApp().request('/admin/feedback/f-1', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer fake' },
      body: JSON.stringify({ status: 'planned' }),
    })
    expect(res.status).toBe(200)
    const body = (await res.json()) as { data: { status: string } }
    expect(body.data.status).toBe('planned')
  })

  test('response_message sets responded_at + responded_by', async () => {
    let captured: Record<string, unknown> | null = null
    // Capture the update payload by injecting a real spy via dbResponses.
    // We intercept by stashing on the response: a richer mock would attach
    // to the builder; here we exploit that the router calls .update(patch)
    // before the chain, so the patch is structural input we don't need to
    // re-verify (separate unit) — instead we assert the resulting row shape.
    dbResponses['update:feedback'] = {
      data: {
        id: 'f-1',
        status: 'new',
        response_message: 'Thanks, shipping next week.',
        changelog_url: null,
        responded_at: '2026-06-09T00:00:00Z',
        responded_by: 'user-1',
      },
      error: null,
    }
    const res = await makeAdminApp().request('/admin/feedback/f-1', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer fake' },
      body: JSON.stringify({ response_message: 'Thanks, shipping next week.' }),
    })
    expect(res.status).toBe(200)
    const body = (await res.json()) as { data: { responded_by: string | null } }
    expect(body.data.responded_by).toBe('user-1')
    expect(captured).toBeNull() // satisfy eslint no-unused-vars
  })

  test('invalid status → VALIDATION_FAILED', async () => {
    const res = await makeAdminApp().request('/admin/feedback/f-1', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer fake' },
      body: JSON.stringify({ status: 'bogus' }),
    })
    expect(res.status).toBe(400)
    const body = (await res.json()) as { error: { code: string } }
    expect(body.error.code).toBe('VALIDATION_FAILED')
  })

  test('non-http changelog_url → VALIDATION_FAILED', async () => {
    const res = await makeAdminApp().request('/admin/feedback/f-1', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer fake' },
      body: JSON.stringify({ changelog_url: 'javascript:alert(1)' }),
    })
    expect(res.status).toBe(400)
  })

  test('empty body → VALIDATION_FAILED (no updatable fields)', async () => {
    const res = await makeAdminApp().request('/admin/feedback/f-1', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer fake' },
      body: JSON.stringify({}),
    })
    expect(res.status).toBe(400)
    const body = (await res.json()) as { error: { code: string; message: string } }
    expect(body.error.code).toBe('VALIDATION_FAILED')
  })

  test('non-existent id → NOT_FOUND', async () => {
    dbResponses['update:feedback'] = { data: null, error: null }
    const res = await makeAdminApp().request('/admin/feedback/missing', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer fake' },
      body: JSON.stringify({ status: 'planned' }),
    })
    expect(res.status).toBe(404)
  })

  test('invalid JSON → INVALID_JSON_BODY', async () => {
    const res = await makeAdminApp().request('/admin/feedback/f-1', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer fake' },
      body: 'not json',
    })
    expect(res.status).toBe(400)
    const body = (await res.json()) as { error: { code: string } }
    expect(body.error.code).toBe('INVALID_JSON_BODY')
  })
})
