/**
 * Session re-issue 2FA flags — S1-001 / S1-002 / S1-003 regression tests.
 */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
import { TenantTier } from '@zync/types'
import type { SessionPayload, TenantId, UserId } from '@zync/types'

const TENANT_A = '00000000-0000-4000-8000-000000000001' as TenantId
const TENANT_B = '00000000-0000-4000-8000-000000000002' as TenantId
const USER_ID = '00000000-0000-4000-8000-000000000099' as UserId

const mockGetTenant2FASettings = vi.fn()
const mockGetMembershipView = vi.fn()
const mockGetPermissionsForRole = vi.fn()
const mockGetUserVersion = vi.fn()
const mockRotateRefreshToken = vi.fn()
const mockGetActiveRefreshTokenByHash = vi.fn()
const mockCountActiveRefreshTokens = vi.fn()
const mockInsertRefreshToken = vi.fn()
const mockAcceptInvitationExistingUser = vi.fn()
const mockGetInvitationByHash = vi.fn()
const mockGetTenantById = vi.fn()
const mockGetRoleById = vi.fn()
const mockFindUserByEmail = vi.fn()
const mockGetInvitationPublicMetadataByHash = vi.fn()
const mockSignSession = vi.fn()
const mockVerifySession = vi.fn()
const mockHashToken = vi.fn()
const mockVerifyPassword = vi.fn()
const mockBuildSessionPayload = vi.fn()
const mockRecordSessionOnLogin = vi.fn()
const mockRevokeSessionByTokenHash = vi.fn()
const mockGetTenantSecuritySettings = vi.fn()
const mockCountActiveSessions = vi.fn()
const mockBlocklistToken = vi.fn()
const mockGetUser2FAStatus = vi.fn()
const mockFindValidTrustedDevice = vi.fn()
const mockInsertMagicLinkToken = vi.fn()

vi.mock('@zync/db/queries', () => ({
  createDb: vi.fn(() => ({})),
  getTenant2FASettings: (...args: unknown[]) => mockGetTenant2FASettings(...args),
  getMembershipView: (...args: unknown[]) => mockGetMembershipView(...args),
  getPermissionsForRole: (...args: unknown[]) => mockGetPermissionsForRole(...args),
  rotateRefreshToken: (...args: unknown[]) => mockRotateRefreshToken(...args),
  getActiveRefreshTokenByHash: (...args: unknown[]) => mockGetActiveRefreshTokenByHash(...args),
  countActiveRefreshTokens: (...args: unknown[]) => mockCountActiveRefreshTokens(...args),
  insertRefreshToken: (...args: unknown[]) => mockInsertRefreshToken(...args),
  acceptInvitationExistingUser: (...args: unknown[]) => mockAcceptInvitationExistingUser(...args),
  getInvitationByHash: (...args: unknown[]) => mockGetInvitationByHash(...args),
  getTenantById: (...args: unknown[]) => mockGetTenantById(...args),
  getRoleById: (...args: unknown[]) => mockGetRoleById(...args),
  findUserByEmail: (...args: unknown[]) => mockFindUserByEmail(...args),
  getInvitationPublicMetadataByHash: (...args: unknown[]) => mockGetInvitationPublicMetadataByHash(...args),
  revokeSessionByTokenHash: (...args: unknown[]) => mockRevokeSessionByTokenHash(...args),
  getTenantSecuritySettings: (...args: unknown[]) => mockGetTenantSecuritySettings(...args),
  countActiveSessions: (...args: unknown[]) => mockCountActiveSessions(...args),
  getUser2FAStatus: (...args: unknown[]) => mockGetUser2FAStatus(...args),
  findValidTrustedDevice: (...args: unknown[]) => mockFindValidTrustedDevice(...args),
  insertMagicLinkToken: (...args: unknown[]) => mockInsertMagicLinkToken(...args),
}))

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

vi.mock('@zync/auth', async (importOriginal) => {
  const actual = await importOriginal<typeof import('@zync/auth')>()
  return {
    ...actual,
    buildSessionPayload: (...args: unknown[]) => mockBuildSessionPayload(...args),
    signSession: (...args: unknown[]) => mockSignSession(...args),
    verifySession: (...args: unknown[]) => mockVerifySession(...args),
    hashToken: (...args: unknown[]) => mockHashToken(...args),
    verifyPassword: (...args: unknown[]) => mockVerifyPassword(...args),
    generateOpaqueToken: vi.fn(() => 'new-opaque-token'),
    recordSessionOnLogin: (...args: unknown[]) => mockRecordSessionOnLogin(...args),
    blocklistToken: (...args: unknown[]) => mockBlocklistToken(...args),
  }
})

import { loadTwoFactorOptsForTenant } from '../src/lib/session-two-factor'
import { refreshRoute } from '../src/routes/auth/refresh'
import { switchTenantRoute } from '../src/routes/auth/switch-tenant'
import { inviteAcceptRoute } from '../src/routes/auth/invite'
import type { AppEnv } from '../src/types'

const mockEnv = {
  DB: { connectionString: 'postgresql://test:test@localhost/test' },
  JWT_SECRET: 'test-jwt-secret',
  RATELIMIT_KV: {
    get: vi.fn(async () => null),
    put: vi.fn(async () => undefined),
  },
} as AppEnv['Bindings']

function allowSessionCap() {
  mockGetTenantSecuritySettings.mockResolvedValue({ maxSessionsPerUser: 10 })
  mockCountActiveSessions.mockResolvedValue(0)
}

function membership(tenantId: TenantId) {
  return {
    tenantId,
    tenantSlug: 'acme',
    tier: TenantTier.FREELANCER,
    roleId: 'role-1',
    role: 'OWNER',
    status: 'active',
  }
}

function sessionPayload(overrides: Partial<SessionPayload> = {}): SessionPayload {
  return {
    sub: USER_ID,
    tid: TENANT_A,
    role: 'OWNER',
    permissions: ['settings:read'],
    tier: TenantTier.FREELANCER,
    type: 'user',
    v: 1,
    enforce_2fa: false,
    two_factor_verified: false,
    exp: Math.floor(Date.now() / 1000) + 3600,
    iat: Math.floor(Date.now() / 1000),
    ...overrides,
  }
}

describe('loadTwoFactorOptsForTenant', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('derives enforce2fa from tenant settings with no prior session', async () => {
    mockGetTenant2FASettings.mockResolvedValue({ enforce2fa: true, disable2faRememberDevice: false })

    await expect(loadTwoFactorOptsForTenant({} as never, TENANT_B)).resolves.toEqual({
      enforce2fa: true,
      twoFactorVerified: false,
    })
  })

  it('carries twoFactorVerified only for the same tenant on refresh', async () => {
    mockGetTenant2FASettings.mockResolvedValue({ enforce2fa: true, disable2faRememberDevice: false })

    await expect(
      loadTwoFactorOptsForTenant({} as never, TENANT_A, sessionPayload({
        tid: TENANT_A,
        two_factor_verified: true,
      })),
    ).resolves.toEqual({
      enforce2fa: true,
      twoFactorVerified: true,
    })

    await expect(
      loadTwoFactorOptsForTenant({} as never, TENANT_B, sessionPayload({
        tid: TENANT_A,
        two_factor_verified: true,
      })),
    ).resolves.toEqual({
      enforce2fa: true,
      twoFactorVerified: false,
    })
  })
})

describe('POST /api/auth/refresh (S1-001)', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    allowSessionCap()
    mockRevokeSessionByTokenHash.mockResolvedValue(null)
    mockRecordSessionOnLogin.mockResolvedValue({ id: 'session-id' })
    mockHashToken.mockResolvedValue('refresh-hash')
    mockGetActiveRefreshTokenByHash.mockResolvedValue({
      userId: USER_ID,
      tenantId: TENANT_A,
    })
    mockCountActiveRefreshTokens.mockResolvedValue(1)
    mockGetMembershipView.mockResolvedValue(membership(TENANT_A))
    mockGetPermissionsForRole.mockResolvedValue(['settings:read'])
    mockGetUserVersion.mockResolvedValue(1)
    mockGetTenant2FASettings.mockResolvedValue({ enforce2fa: true, disable2faRememberDevice: false })
    mockRotateRefreshToken.mockResolvedValue(true)
    mockSignSession.mockResolvedValue('signed-access-token')
    mockBuildSessionPayload.mockImplementation((args) => ({
      sub: args.user.id,
      tid: args.tenant.id,
      enforce_2fa: args.enforce2fa ?? false,
      two_factor_verified: args.twoFactorVerified ?? false,
    }))
    mockVerifySession.mockResolvedValue(
      sessionPayload({ tid: TENANT_A, enforce_2fa: true, two_factor_verified: false }),
    )
  })

  it('keeps enforce_2fa and pending verification on refresh for the same tenant', async () => {
    const app = new Hono<AppEnv>()
    app.route('/', refreshRoute)

    const res = await app.request(
      '/refresh',
      {
        method: 'POST',
        headers: {
          Origin: 'https://app.zync.is',
          Cookie: 'zync_refresh=presented-refresh; zync_session=prior-session',
        },
      },
      mockEnv,
    )

    expect(res.status).toBe(200)
    expect(mockBuildSessionPayload).toHaveBeenCalledWith(
      expect.objectContaining({
        enforce2fa: true,
        twoFactorVerified: false,
      }),
    )
  })

  it('preserves two_factor_verified when the prior session verified 2FA for the same tenant', async () => {
    mockVerifySession.mockResolvedValue(
      sessionPayload({ tid: TENANT_A, enforce_2fa: true, two_factor_verified: true }),
    )

    const app = new Hono<AppEnv>()
    app.route('/', refreshRoute)

    await app.request(
      '/refresh',
      {
        method: 'POST',
        headers: {
          Origin: 'https://app.zync.is',
          Cookie: 'zync_refresh=presented-refresh; zync_session=prior-session',
        },
      },
      mockEnv,
    )

    expect(mockBuildSessionPayload).toHaveBeenCalledWith(
      expect.objectContaining({
        enforce2fa: true,
        twoFactorVerified: true,
      }),
    )
  })
})

describe('POST /api/auth/switch-tenant (S1-002)', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    allowSessionCap()
    mockGetMembershipView.mockResolvedValue(membership(TENANT_B))
    mockGetPermissionsForRole.mockResolvedValue(['settings:read'])
    mockGetUserVersion.mockResolvedValue(1)
    mockGetTenant2FASettings.mockResolvedValue({ enforce2fa: true, disable2faRememberDevice: false })
    mockGetUser2FAStatus.mockResolvedValue({ twoFactorEnabled: false })
    mockFindValidTrustedDevice.mockResolvedValue(null)
    mockInsertMagicLinkToken.mockResolvedValue(undefined)
    mockInsertRefreshToken.mockResolvedValue(undefined)
    mockRecordSessionOnLogin.mockResolvedValue({ id: 'session-id' })
    mockSignSession.mockResolvedValue('signed-access-token')
    mockBuildSessionPayload.mockImplementation((args) => ({
      sub: args.user.id,
      tid: args.tenant.id,
      enforce_2fa: args.enforce2fa ?? false,
      two_factor_verified: args.twoFactorVerified ?? false,
    }))
  })

  it('requires 2FA for an enforcing target tenant and never carries verification across tenants', async () => {
    const app = new Hono<AppEnv>()
    app.use('*', async (c, next) => {
      c.set('session', sessionPayload({
        tid: TENANT_A,
        enforce_2fa: false,
        two_factor_verified: true,
      }))
      await next()
    })
    app.route('/', switchTenantRoute)

    const res = await app.request(
      '/switch-tenant',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Cookie: 'zync_session=verified-on-tenant-a',
        },
        body: JSON.stringify({ tenantId: TENANT_B }),
      },
      mockEnv,
    )

    expect(res.status).toBe(200)
    expect(mockGetTenant2FASettings).toHaveBeenCalledWith(expect.anything(), TENANT_B)
    const body = (await res.json()) as { requires_2fa_setup: boolean; session_token: string }
    expect(body.requires_2fa_setup).toBe(true)
    expect(body.session_token).toMatch(/^temp_/)
    expect(mockInsertMagicLinkToken).toHaveBeenCalledWith(
      expect.anything(),
      expect.objectContaining({
        tenantId: TENANT_B,
        userId: USER_ID,
        purpose: 'pending_2fa_setup',
      }),
    )
    expect(mockBuildSessionPayload).not.toHaveBeenCalled()
  })
})

describe('POST /api/auth/invite/accept (S1-003)', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    allowSessionCap()
    mockHashToken.mockResolvedValue('invite-hash')
    mockGetInvitationByHash.mockResolvedValue({
      id: 'invite-1',
      tenantId: TENANT_B,
      email: 'member@example.com',
      roleId: 'role-1',
    })
    mockGetTenantById.mockResolvedValue({
      id: TENANT_B,
      requireApproval: false,
      tier: TenantTier.FREELANCER,
    })
    mockGetRoleById.mockResolvedValue({ id: 'role-1', name: 'OWNER' })
    mockFindUserByEmail.mockResolvedValue({
      id: USER_ID,
      email: 'member@example.com',
      passwordHash: 'stored-password-hash',
    })
    mockVerifyPassword.mockResolvedValue(true)
    mockAcceptInvitationExistingUser.mockResolvedValue(undefined)
    mockGetMembershipView.mockResolvedValue(membership(TENANT_B))
    mockGetPermissionsForRole.mockResolvedValue(['settings:read'])
    mockGetUserVersion.mockResolvedValue(1)
    mockGetTenant2FASettings.mockResolvedValue({ enforce2fa: true, disable2faRememberDevice: false })
    mockGetUser2FAStatus.mockResolvedValue({ twoFactorEnabled: false })
    mockFindValidTrustedDevice.mockResolvedValue(null)
    mockInsertMagicLinkToken.mockResolvedValue(undefined)
    mockInsertRefreshToken.mockResolvedValue(undefined)
    mockRecordSessionOnLogin.mockResolvedValue({ id: 'session-id' })
    mockSignSession.mockResolvedValue('signed-access-token')
    mockBuildSessionPayload.mockImplementation((args) => ({
      sub: args.user.id,
      tid: args.tenant.id,
      enforce_2fa: args.enforce2fa ?? false,
      two_factor_verified: args.twoFactorVerified ?? false,
    }))
  })

  it('issues a session that enforces 2FA without marking it verified on accept', async () => {
    const app = new Hono<AppEnv>()
    app.route('/', inviteAcceptRoute)

    const res = await app.request(
      '/invite/accept',
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token: 'invite-plaintext', password: 'correct-password' }),
      },
      mockEnv,
    )

    expect(res.status).toBe(200)
    expect(mockGetTenant2FASettings).toHaveBeenCalledWith(expect.anything(), TENANT_B)
    const body = (await res.json()) as {
      status: string
      requires_2fa_setup: boolean
      session_token: string
    }
    expect(body.status).toBe('joined')
    expect(body.requires_2fa_setup).toBe(true)
    expect(body.session_token).toMatch(/^temp_/)
    expect(mockInsertMagicLinkToken).toHaveBeenCalledWith(
      expect.anything(),
      expect.objectContaining({
        tenantId: TENANT_B,
        userId: USER_ID,
        purpose: 'pending_2fa_setup',
      }),
    )
    expect(mockBuildSessionPayload).not.toHaveBeenCalled()
  })

  it('rejects an existing invited user with the wrong password', async () => {
    mockVerifyPassword.mockResolvedValue(false)

    const app = new Hono<AppEnv>()
    app.route('/', inviteAcceptRoute)

    const res = await app.request(
      '/invite/accept',
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token: 'invite-plaintext', password: 'wrong-password' }),
      },
      mockEnv,
    )

    expect(res.status).toBe(401)
    const body = (await res.json()) as { error: string }
    expect(body.error).toBe('invalid_password')
    expect(mockAcceptInvitationExistingUser).not.toHaveBeenCalled()
  })
})
