/**
 * Session lifecycle regression — A12b cluster (session-security spec).
 */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
import { TenantTier } from '@zync/types'
import type { TenantId, UserId } from '@zync/types'
import type { AppEnv } from '../src/types'

const TENANT_ID = '00000000-0000-4000-8000-000000000001' as TenantId
const USER_ID = '00000000-0000-4000-8000-000000000099' as UserId
const SESSION_ID = '00000000-0000-4000-8000-000000000010'
const OTHER_SESSION_ID = '00000000-0000-4000-8000-000000000011'
const CURRENT_HASH = 'current-token-hash'
const OTHER_HASH = 'other-token-hash'

const mockSession = {
  type: 'user' as const,
  sub: USER_ID,
  tid: TENANT_ID,
  email: 'user@example.com',
  name: 'User',
  role: 'OWNER',
  permissions: [],
  tier: TenantTier.FREELANCER,
  v: 1,
  exp: Math.floor(Date.now() / 1000) + 3600,
}

const mockListUserSessions = vi.fn()
const mockRevokeSession = vi.fn()
const mockRevokeOtherUserSessions = vi.fn()
const mockGetSessionByTokenHash = vi.fn()
const mockTouchSession = vi.fn()
const mockRevokeSessionByTokenHash = vi.fn()
const mockRecordSessionOnLogin = vi.fn()
const mockBlocklistRevokedTokens = vi.fn()
const mockBlocklistToken = vi.fn()
const mockCountActiveSessions = vi.fn()
const mockGetTenantSecuritySettings = vi.fn()
const mockGetPrimaryMembership = vi.fn()
const mockGetPermissionsForRole = vi.fn()
const mockGetUserVersion = vi.fn()
const mockInsertRefreshToken = vi.fn()
const mockFindUserByEmail = vi.fn()
const mockVerifyPassword = vi.fn()
const mockGetPrimaryMembershipWithTenant = vi.fn()
const mockGetUser2FAStatus = vi.fn()
const mockGetTenant2FASettings = vi.fn()
const mockGetUserTheme = vi.fn()

vi.mock('../src/middleware/auth', () => ({
  authMiddleware: async (
    c: { set: (key: string, value: unknown) => void },
    next: () => Promise<void>,
  ) => {
    c.set('db', {})
    c.set('session', mockSession)
    c.set('accessTokenHash', CURRENT_HASH)
    await next()
  },
}))

vi.mock('../src/middleware/user-version', () => ({
  getUserVersion: (...args: unknown[]) => mockGetUserVersion(...args),
}))

vi.mock('@zync/db/queries', async (importOriginal) => {
  const actual = await importOriginal<typeof import('@zync/db/queries')>()
  return {
    ...actual,
    createDb: vi.fn(() => ({})),
    listUserSessions: (...args: unknown[]) => mockListUserSessions(...args),
    revokeSession: (...args: unknown[]) => mockRevokeSession(...args),
    revokeOtherUserSessions: (...args: unknown[]) => mockRevokeOtherUserSessions(...args),
    getSessionByTokenHash: (...args: unknown[]) => mockGetSessionByTokenHash(...args),
    touchSession: (...args: unknown[]) => mockTouchSession(...args),
    revokeSessionByTokenHash: (...args: unknown[]) => mockRevokeSessionByTokenHash(...args),
    countActiveSessions: (...args: unknown[]) => mockCountActiveSessions(...args),
    getTenantSecuritySettings: (...args: unknown[]) => mockGetTenantSecuritySettings(...args),
    getPrimaryMembership: (...args: unknown[]) => mockGetPrimaryMembership(...args),
    getPermissionsForRole: (...args: unknown[]) => mockGetPermissionsForRole(...args),
    insertRefreshToken: (...args: unknown[]) => mockInsertRefreshToken(...args),
    findUserByEmail: (...args: unknown[]) => mockFindUserByEmail(...args),
    getPrimaryMembershipWithTenant: (...args: unknown[]) => mockGetPrimaryMembershipWithTenant(...args),
    getUser2FAStatus: (...args: unknown[]) => mockGetUser2FAStatus(...args),
    getTenant2FASettings: (...args: unknown[]) => mockGetTenant2FASettings(...args),
    getUserTheme: (...args: unknown[]) => mockGetUserTheme(...args),
  }
})

vi.mock('@zync/auth', async (importOriginal) => {
  const actual = await importOriginal<typeof import('@zync/auth')>()
  return {
    ...actual,
    recordSessionOnLogin: (...args: unknown[]) => mockRecordSessionOnLogin(...args),
    blocklistRevokedTokens: (...args: unknown[]) => mockBlocklistRevokedTokens(...args),
    blocklistToken: (...args: unknown[]) => mockBlocklistToken(...args),
    timingSafeEqual: (a: string, b: string) => a === b,
    verifyPassword: (...args: unknown[]) => mockVerifyPassword(...args),
    signSession: vi.fn(async () => 'signed-access-token'),
    hashToken: vi.fn(async (value: string) => `hash:${value}`),
    generateOpaqueToken: vi.fn(() => 'opaque-refresh-token'),
    buildSessionPayload: vi.fn((payload: unknown) => payload),
  }
})

vi.mock('../src/lib/login-throttle', () => ({
  isLockedOut: vi.fn(async () => false),
  recordFailure: vi.fn(async () => undefined),
  clearFailures: vi.fn(async () => undefined),
}))

import { sessionsRoute } from '../src/routes/sessions'
import { logoutRoute } from '../src/routes/auth/logout'
import { loginRoute } from '../src/routes/auth/login'
import { issueSessionForUser } from '../src/lib/issue-session'

const ORIGIN = 'https://app.zync.is'

const mockEnv = {
  DB: { connectionString: 'postgresql://test:test@localhost/test' },
  JWT_SECRET: 'test-jwt-secret',
  RATELIMIT_KV: { put: vi.fn(async () => undefined) },
  KV: { put: vi.fn(async () => undefined) },
  AUDIT_QUEUE: { send: vi.fn(async () => undefined) },
  RATE_LIMITER_AUTH: { limit: vi.fn(async () => ({ success: true })) },
} as AppEnv['Bindings']

function sessionsApp() {
  const app = new Hono<AppEnv>()
  app.route('/api/user/sessions', sessionsRoute)
  return app
}

function logoutApp() {
  const app = new Hono<AppEnv>()
  app.use('*', async (c, next) => {
    c.set('db', {})
    c.set('session', mockSession)
    c.set('accessTokenHash', CURRENT_HASH)
    await next()
  })
  app.route('/api/auth', logoutRoute)
  return app
}

function loginApp() {
  const app = new Hono<AppEnv>()
  app.route('/api/auth', loginRoute)
  return app
}

describe('session routes (A12b)', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockListUserSessions.mockResolvedValue([
      {
        id: SESSION_ID,
        tokenHash: CURRENT_HASH,
        deviceName: 'Chrome on macOS',
        ipAddress: '1.2.3.4',
        countryCode: 'IL',
        createdAt: new Date(),
        lastActiveAt: new Date(),
        expiresAt: new Date(Date.now() + 3600_000),
      },
      {
        id: OTHER_SESSION_ID,
        tokenHash: OTHER_HASH,
        deviceName: 'Safari on iOS',
        ipAddress: '5.6.7.8',
        countryCode: 'IL',
        createdAt: new Date(),
        lastActiveAt: new Date(),
        expiresAt: new Date(Date.now() + 7200_000),
      },
    ])
    mockGetSessionByTokenHash.mockResolvedValue({
      id: SESSION_ID,
      tenantId: TENANT_ID,
      revokedAt: null,
    })
    mockTouchSession.mockResolvedValue(undefined)
    mockRevokeSession.mockResolvedValue({
      tokens: [{ tokenHash: OTHER_HASH, expiresAt: new Date(Date.now() + 7200_000) }],
    })
    mockRevokeOtherUserSessions.mockResolvedValue({ tokens: [] })
  })

  it('POST /keepalive touches the current session row', async () => {
    const res = await sessionsApp().request('/api/user/sessions/keepalive', {
      method: 'POST',
      headers: { Origin: ORIGIN },
    }, mockEnv)

    expect(res.status).toBe(200)
    expect(mockGetSessionByTokenHash).toHaveBeenCalledWith({}, CURRENT_HASH)
    expect(mockTouchSession).toHaveBeenCalledWith({}, TENANT_ID, SESSION_ID)
  })

  it('GET / lists sessions without settings permissions', async () => {
    const res = await sessionsApp().request('/api/user/sessions', {
      headers: { Origin: ORIGIN },
    }, mockEnv)

    expect(res.status).toBe(200)
    const body = (await res.json()) as { sessions: Array<{ isCurrent: boolean }> }
    expect(body.sessions).toHaveLength(2)
    expect(body.sessions[0]?.isCurrent).toBe(true)
  })

  it('DELETE /:id returns 400 cannot_revoke_current for the current session', async () => {
    const res = await sessionsApp().request(`/api/user/sessions/${SESSION_ID}`, {
      method: 'DELETE',
      headers: { Origin: ORIGIN },
    }, mockEnv)

    expect(res.status).toBe(400)
    const body = (await res.json()) as { error: string }
    expect(body.error).toBe('cannot_revoke_current')
    expect(mockRevokeSession).not.toHaveBeenCalled()
  })

  it('DELETE /:id blocklists with each session expires_at', async () => {
    const expiresAt = new Date(Date.now() + 7200_000)
    mockRevokeSession.mockResolvedValue({
      tokens: [{ tokenHash: OTHER_HASH, expiresAt }],
    })

    const res = await sessionsApp().request(`/api/user/sessions/${OTHER_SESSION_ID}`, {
      method: 'DELETE',
      headers: { Origin: ORIGIN },
    }, mockEnv)

    expect(res.status).toBe(200)
    expect(mockBlocklistRevokedTokens).toHaveBeenCalledWith(
      expect.anything(),
      [{ tokenHash: OTHER_HASH, expiresAt }],
    )
  })
})

describe('issue-session records user_sessions rows (A12b-004)', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockGetPrimaryMembership.mockResolvedValue({
      tenantId: TENANT_ID,
      tenantSlug: 'acme',
      tier: TenantTier.FREELANCER,
      roleId: 'role-1',
      role: 'OWNER',
    })
    mockGetPermissionsForRole.mockResolvedValue([])
    mockGetUserVersion.mockResolvedValue(0)
    mockInsertRefreshToken.mockResolvedValue(undefined)
    mockRecordSessionOnLogin.mockResolvedValue({ id: SESSION_ID })
    mockGetTenantSecuritySettings.mockResolvedValue({ maxSessionsPerUser: 10 })
    mockCountActiveSessions.mockResolvedValue(0)
  })

  it('calls recordSessionOnLogin when issuing a session', async () => {
    const c = {
      env: { JWT_SECRET: 'secret' },
      req: {
        header: (name: string) => {
          if (name === 'User-Agent') return 'Mozilla/5.0 Chrome'
          if (name === 'CF-Connecting-IP') return '203.0.113.1'
          if (name === 'CF-IPCountry') return 'IL'
          return undefined
        },
      },
      header: (name: string) => {
        if (name === 'User-Agent') return 'Mozilla/5.0 Chrome'
        if (name === 'CF-Connecting-IP') return '203.0.113.1'
        if (name === 'CF-IPCountry') return 'IL'
        return undefined
      },
    }

    await issueSessionForUser(c as never, {} as never, USER_ID)

    expect(mockRecordSessionOnLogin).toHaveBeenCalledWith(
      {},
      expect.objectContaining({
        tenantId: TENANT_ID,
        userId: USER_ID,
        accessToken: 'signed-access-token',
        userAgent: 'Mozilla/5.0 Chrome',
        ip: '203.0.113.1',
        countryCode: 'IL',
      }),
    )
  })
})

describe('login session cap (A12b-009)', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockFindUserByEmail.mockResolvedValue({
      id: USER_ID,
      email: 'user@example.com',
      passwordHash: 'hash',
      emailVerifiedAt: new Date(),
    })
    mockVerifyPassword.mockResolvedValue(true)
    mockGetPrimaryMembershipWithTenant.mockResolvedValue({
      tenantId: TENANT_ID,
      tenantSlug: 'acme',
      tier: TenantTier.FREELANCER,
      roleId: 'role-1',
      role: 'OWNER',
    })
    mockGetUser2FAStatus.mockResolvedValue({ twoFactorEnabled: false })
    mockGetTenant2FASettings.mockResolvedValue({ enforce2fa: false })
    mockGetTenantSecuritySettings.mockResolvedValue({ maxSessionsPerUser: 2 })
    mockCountActiveSessions.mockResolvedValue(2)
  })

  it('returns 429 when max_sessions_per_user is reached', async () => {
    const res = await loginApp().request('/api/auth/login', {
      method: 'POST',
      headers: {
        Origin: ORIGIN,
        'Content-Type': 'application/json',
        'CF-Connecting-IP': '203.0.113.10',
      },
      body: JSON.stringify({ email: 'user@example.com', password: 'password123' }),
    }, mockEnv)

    expect(res.status).toBe(429)
    const body = (await res.json()) as { error: string }
    expect(body.error).toBe(
      'Maximum active sessions reached. Sign out of another device to continue.',
    )
  })
})

describe('logout session revocation (A12b-020)', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    const expiresAt = new Date(Date.now() + 3600_000)
    mockRevokeSessionByTokenHash.mockResolvedValue({
      tokenHash: CURRENT_HASH,
      expiresAt,
    })
  })

  it('revokes user_sessions row and writes session:revoked KV key', async () => {
    const kvStore = new Map<string, string>()
    const ratelimitKv = {
      put: vi.fn(async (key: string, value: string) => {
        kvStore.set(key, value)
      }),
    }
    const legacyKv = {
      put: vi.fn(async () => undefined),
    }

    const res = await logoutApp().request('/api/auth/logout', {
      method: 'POST',
      headers: { Origin: ORIGIN },
    }, {
      RATELIMIT_KV: ratelimitKv,
      KV: legacyKv,
    } as never)

    expect(res.status).toBe(204)
    expect(mockRevokeSessionByTokenHash).toHaveBeenCalledWith({}, CURRENT_HASH, 'user')
    expect(mockBlocklistToken).toHaveBeenCalledWith(
      ratelimitKv,
      CURRENT_HASH,
      expect.any(Date),
    )
    expect(legacyKv.put).toHaveBeenCalledWith(
      `blocklist:${CURRENT_HASH}`,
      '1',
      expect.objectContaining({ expirationTtl: expect.any(Number) }),
    )
  })
})

describe('blocklistRevokedTokens per-row TTL (A12b-019)', () => {
  it('uses each entry expires_at independently', async () => {
    const { blocklistRevokedTokens } = await vi.importActual<typeof import('@zync/auth')>('@zync/auth')
    const kv = {
      put: vi.fn(async () => undefined),
    } as unknown as KVNamespace

    const short = new Date(Date.now() + 120_000)
    const long = new Date(Date.now() + 7200_000)

    await blocklistRevokedTokens(kv, [
      { tokenHash: 'hash-a', expiresAt: short },
      { tokenHash: 'hash-b', expiresAt: long },
    ])

    expect(kv.put).toHaveBeenCalledTimes(2)
    expect(kv.put).toHaveBeenCalledWith('session:revoked:hash-a', '1', {
      expirationTtl: expect.any(Number),
    })
    expect(kv.put).toHaveBeenCalledWith('session:revoked:hash-b', '1', {
      expirationTtl: expect.any(Number),
    })

    const ttlA = vi.mocked(kv.put).mock.calls[0]?.[2]?.expirationTtl as number
    const ttlB = vi.mocked(kv.put).mock.calls[1]?.[2]?.expirationTtl as number
    expect(ttlB).toBeGreaterThan(ttlA)
  })
})
