import { act, render, renderHook, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { Principal, SignInCredentials } from '@platform-modules/auth'
import {
  AuthProvider,
  AuthProviderError,
  AuthSessionExpiredError,
  isAuthProviderError,
  isAuthSessionExpiredError,
  RequireCapability,
  RequireRole,
  useCurrentUser,
  usePasswordReset,
  useSignIn,
  useSignOut,
  useSignUp,
  type AuthClient,
  type SignUpInput,
} from './index'

function makePrincipal(overrides: Partial<Principal> = {}): Principal {
  return {
    userId: 'user-1',
    sessionId: 'sess-1',
    roles: ['member'],
    capabilities: ['posts:read'],
    ...overrides,
  }
}

type FakeAuthClient = AuthClient & { state: { user: Principal | null } }

function createFakeClient(seed: Principal | null = null): FakeAuthClient {
  const state = { user: seed }
  return {
    state,
    async getCurrentUser() {
      return state.user
    },
    async signIn(creds: SignInCredentials) {
      const principal = makePrincipal({ userId: creds.email })
      state.user = principal
      return principal
    },
    async signUp(input: SignUpInput) {
      const principal = makePrincipal({ userId: input.email })
      state.user = principal
      return principal
    },
    async signOut() {
      state.user = null
    },
    async requestPasswordReset() {
      // compliant route: always resolves — no account enumeration
    },
    async resetPassword() {},
  }
}

function withAuth(client: AuthClient) {
  return function Wrapper({ children }: { children: React.ReactNode }) {
    return <AuthProvider client={client}>{children}</AuthProvider>
  }
}

describe('AuthProvider', () => {
  it('refreshes the current Principal from the client on mount', async () => {
    const client = createFakeClient(makePrincipal({ userId: 'mounted-user' }))
    const Child = () => {
      const { user } = useCurrentUser()
      return <span data-testid="uid">{user?.userId ?? 'none'}</span>
    }

    render(
      <AuthProvider client={client}>
        <Child />
      </AuthProvider>,
    )

    await waitFor(() => expect(screen.getByTestId('uid').textContent).toBe('mounted-user'))
  })
})

describe('useCurrentUser', () => {
  it('reload() refetches without rejecting when getCurrentUser fails', async () => {
    const client = createFakeClient(makePrincipal())
    const getSpy = vi.spyOn(client, 'getCurrentUser')
    getSpy.mockRejectedValueOnce(new AuthSessionExpiredError())
    getSpy.mockResolvedValueOnce(makePrincipal({ userId: 'recovered' }))

    const { result } = renderHook(() => useCurrentUser(), { wrapper: withAuth(client) })

    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.error).toBeInstanceOf(AuthSessionExpiredError)
    expect(result.current.user).toBeNull()

    act(() => result.current.reload())
    await waitFor(() => expect(result.current.user?.userId).toBe('recovered'))
    expect(result.current.error).toBeNull()
  })
})

describe('useSignIn', () => {
  it('captures a failed sign-in into error and rejects the returned promise', async () => {
    const client = createFakeClient()
    vi.spyOn(client, 'signIn').mockRejectedValue(new AuthSessionExpiredError('bad creds'))
    const { result } = renderHook(() => useSignIn(), { wrapper: withAuth(client) })

    let caught: unknown
    await act(async () => {
      try {
        await result.current.signIn({ email: 'a@b.c', password: 'x' })
      } catch (e) {
        caught = e
      }
    })

    expect(isAuthSessionExpiredError(caught)).toBe(true)
    expect(result.current.error).toBeInstanceOf(AuthSessionExpiredError)
    expect(result.current.pending).toBe(false)
  })
})

describe('useSignUp', () => {
  it('signUp accepts SignUpInput and updates the session user', async () => {
    const client = createFakeClient()
    const { result } = renderHook(() => useSignUp(), { wrapper: withAuth(client) })

    await act(async () => {
      const principal = await result.current.signUp({
        email: 'new@example.com',
        password: 'secret',
      })
      expect(principal.userId).toBe('new@example.com')
    })

    expect(result.current.error).toBeNull()
    expect(client.state.user?.userId).toBe('new@example.com')
  })

  it('captures a failed sign-up into error and rejects the returned promise', async () => {
    const client = createFakeClient()
    vi.spyOn(client, 'signUp').mockRejectedValue(new Error('email taken'))
    const { result } = renderHook(() => useSignUp(), { wrapper: withAuth(client) })

    let caught: unknown
    await act(async () => {
      try {
        await result.current.signUp({ email: 'dupe@example.com', password: 'x' })
      } catch (e) {
        caught = e
      }
    })

    expect((caught as Error).message).toBe('email taken')
    expect(result.current.error?.message).toBe('email taken')
    expect(result.current.pending).toBe(false)
  })
})

describe('useSignOut', () => {
  it('clears the user and toggles pending while signOut runs', async () => {
    const client = createFakeClient(makePrincipal())
    const { result: userResult } = renderHook(() => useCurrentUser(), { wrapper: withAuth(client) })
    await waitFor(() => expect(userResult.current.loading).toBe(false))

    const { result } = renderHook(() => useSignOut(), { wrapper: withAuth(client) })

    await act(async () => {
      await result.current.signOut()
    })

    expect(client.state.user).toBeNull()
    expect(result.current.pending).toBe(false)
  })

  it('rejects the returned promise when signOut fails and clears pending', async () => {
    const client = createFakeClient(makePrincipal())
    vi.spyOn(client, 'signOut').mockRejectedValue(new AuthSessionExpiredError('gone'))
    const { result } = renderHook(() => useSignOut(), { wrapper: withAuth(client) })

    let caught: unknown
    await act(async () => {
      try {
        await result.current.signOut()
      } catch (e) {
        caught = e
      }
    })

    expect(isAuthSessionExpiredError(caught)).toBe(true)
    expect(result.current.pending).toBe(false)
    // mutation error split: signOut ALSO captures to error state (uniform mutation shape)
    expect(isAuthSessionExpiredError(result.current.error)).toBe(true)
  })
})

describe('usePasswordReset', () => {
  it('request() resolves without surfacing unknown-email errors (no enumeration)', async () => {
    const client = createFakeClient()
    const requestSpy = vi.spyOn(client, 'requestPasswordReset').mockResolvedValue(undefined)
    const { result } = renderHook(() => usePasswordReset(), { wrapper: withAuth(client) })

    await act(async () => {
      await result.current.request('nobody@example.com')
    })

    expect(requestSpy).toHaveBeenCalledWith('nobody@example.com')
    expect(result.current.error).toBeNull()
  })

  it('reset() captures failures into error and rejects the promise', async () => {
    const client = createFakeClient()
    vi.spyOn(client, 'resetPassword').mockRejectedValue(new Error('invalid token'))
    const { result } = renderHook(() => usePasswordReset(), { wrapper: withAuth(client) })

    let caught: unknown
    await act(async () => {
      try {
        await result.current.reset('bad-token', 'new-pass')
      } catch (e) {
        caught = e
      }
    })

    expect(caught).toBeInstanceOf(Error)
    expect((caught as Error).message).toBe('invalid token')
    expect(result.current.error?.message).toBe('invalid token')
  })
})

describe('RequireRole', () => {
  it('renders fallback when the user lacks the required role in the hierarchy', async () => {
    const client = createFakeClient(makePrincipal({ roles: ['viewer'] }))
    render(
      <AuthProvider client={client}>
        <RequireRole role="admin" hierarchy={['viewer', 'editor', 'admin']} fallback={<span>denied</span>}>
          <span>secret</span>
        </RequireRole>
      </AuthProvider>,
    )

    await waitFor(() => expect(screen.getByText('denied')).toBeTruthy())
    expect(screen.queryByText('secret')).toBeNull()
  })
})

describe('RequireCapability', () => {
  it('renders children when hasPermission passes for the current user', async () => {
    const client = createFakeClient(makePrincipal({ capabilities: ['posts:write'] }))
    render(
      <AuthProvider client={client}>
        <RequireCapability capability="posts:write" fallback={<span>denied</span>}>
          <span>allowed</span>
        </RequireCapability>
      </AuthProvider>,
    )

    await waitFor(() => expect(screen.getByText('allowed')).toBeTruthy())
  })

  it('renders fallback when the user lacks the capability', async () => {
    const client = createFakeClient(makePrincipal({ capabilities: ['posts:read'] }))
    render(
      <AuthProvider client={client}>
        <RequireCapability capability="posts:write" fallback={<span>denied</span>}>
          <span>allowed</span>
        </RequireCapability>
      </AuthProvider>,
    )

    await waitFor(() => expect(screen.getByText('denied')).toBeTruthy())
    expect(screen.queryByText('allowed')).toBeNull()
  })
})

describe('AuthSessionExpiredError', () => {
  it('is detected by the structural type guard, not instanceof', () => {
    const err = new AuthSessionExpiredError('gone')
    expect(isAuthSessionExpiredError(err)).toBe(true)
    expect(err.name).toBe('AuthSessionExpiredError')
  })
})

describe('AuthProviderError', () => {
  it('throws when a hook is used outside AuthProvider', () => {
    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
    let caught: unknown
    try {
      renderHook(() => useCurrentUser())
    } catch (e) {
      caught = e
    }
    spy.mockRestore()
    expect(caught).toBeInstanceOf(AuthProviderError)
    expect(isAuthProviderError(caught)).toBe(true)
  })
})

describe('SignUpInput', () => {
  it('is the minimal email/password shape accepted by AuthClient.signUp', async () => {
    const client = createFakeClient()
    const signUpSpy = vi.spyOn(client, 'signUp')
    const { result } = renderHook(() => useSignUp(), { wrapper: withAuth(client) })

    const input: SignUpInput = { email: 'a@b.c', password: 'pw' }
    await act(async () => {
      await result.current.signUp(input)
    })

    expect(signUpSpy).toHaveBeenCalledWith(input)
  })
})

describe('AuthClient', () => {
  it('is satisfied by a fake injected client with no token fields on the surface', async () => {
    const client = createFakeClient()
    const keys = Object.keys(client) as (keyof FakeAuthClient)[]
    expect(keys.some((k) => /token/i.test(String(k)))).toBe(false)
    await expect(client.getCurrentUser()).resolves.toBeNull()
  })
})
