/**
 * @vitest-environment node
 */
import {
  dbChainMock,
  dbChainMockFns,
  hybridAuthMock,
  hybridAuthMockFns,
  McpOauthRedirectRequiredMock,
  mcpOauthMock,
  mcpOauthMockFns,
  permissionsMock,
  permissionsMockFns,
  resetDbChainMock,
  schemaMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('drizzle-orm', () => ({
  and: vi.fn(),
  eq: vi.fn(),
  isNull: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)

import { GET, surfaceOauthError } from './route'

describe('MCP OAuth start route', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    resetDbChainMock()
    hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
      success: true,
      userId: 'user-2',
      userName: 'User Two',
      userEmail: 'user2@example.com',
      authType: 'session',
    })
    permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
    dbChainMockFns.limit.mockResolvedValue([
      {
        id: 'server-1',
        name: 'Exa',
        url: 'https://mcp.exa.ai/mcp',
        workspaceId: 'workspace-1',
        authType: 'oauth',
        deletedAt: null,
      },
    ])
    mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValue({
      id: 'oauth-row-1',
      mcpServerId: 'server-1',
      userId: 'user-1',
      workspaceId: 'workspace-1',
      clientInformation: null,
      tokens: null,
      codeVerifier: null,
      state: null,
      stateCreatedAt: null,
      updatedAt: new Date(),
    })
    mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
    mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValue(
      new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize')
    )
  })

  it('routes OAuth discovery through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
    const request = new NextRequest(
      'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
    )

    await GET(request)

    // The route must call the guarded wrapper (which defaults fetchFn to the
    // SSRF-guarded fetch internally) rather than the raw SDK `auth()` — see
    // apps/sim/lib/mcp/oauth/auth.test.ts for the wrapper's own fetchFn coverage.
    expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledWith(
      expect.anything(),
      expect.objectContaining({ serverUrl: 'https://mcp.exa.ai/mcp' })
    )
  })

  it('requires workspace write permission via MCP auth middleware', async () => {
    const request = new NextRequest(
      'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
    )

    await GET(request)

    expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith(
      'user-2',
      'workspace',
      'workspace-1'
    )
  })

  it('uses a workspace-scoped OAuth row and stamps the latest authorizing user', async () => {
    const request = new NextRequest(
      'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
    )

    const response = await GET(request)
    const body = await response.json()

    expect(response.status).toBe(200)
    expect(body).toEqual({
      status: 'redirect',
      authorizationUrl: 'https://mcp.exa.ai/authorize',
    })
    expect(mcpOauthMockFns.mockGetOrCreateOauthRow).toHaveBeenCalledWith({
      mcpServerId: 'server-1',
      userId: 'user-2',
      workspaceId: 'workspace-1',
    })
    expect(mcpOauthMockFns.mockSetOauthRowUser).toHaveBeenCalledWith('oauth-row-1', 'user-2')
  })

  it('rejects a second user starting OAuth while another authorization is active', async () => {
    mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValueOnce({
      id: 'oauth-row-1',
      mcpServerId: 'server-1',
      userId: 'user-1',
      workspaceId: 'workspace-1',
      clientInformation: null,
      tokens: null,
      codeVerifier: null,
      state: 'hashed-active-state',
      stateCreatedAt: new Date(),
      updatedAt: new Date(),
    })
    const request = new NextRequest(
      'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
    )

    const response = await GET(request)
    const body = await response.json()

    expect(response.status).toBe(409)
    expect(body.error).toBe('OAuth authorization already in progress for this server')
    expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled()
  })

  it('does not leak non-OAuth internal error details to the client', async () => {
    mcpOauthMockFns.mockGetOrCreateOauthRow.mockRejectedValueOnce(
      new Error('connect ECONNREFUSED 10.0.0.5:5432 (internal-db-host)')
    )
    const request = new NextRequest(
      'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1'
    )

    const response = await GET(request)
    const body = await response.json()

    expect(response.status).toBe(500)
    expect(body.error).toBe('Failed to start OAuth flow')
    expect(body.error).not.toContain('ECONNREFUSED')
    expect(body.error).not.toContain('internal-db-host')
  })
})

describe('surfaceOauthError', () => {
  it('uses typed OAuthError errorCode and message for spec-compliant errors', async () => {
    const { InvalidGrantError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js')
    const err = new InvalidGrantError('Refresh token expired')
    expect(surfaceOauthError(err)).toBe('invalid_grant: Refresh token expired')
  })

  it('parses Raw body envelope for ServerError fallbacks (non-spec vendors)', async () => {
    const { ServerError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js')
    const err = new ServerError(
      'HTTP 400: Invalid OAuth error response: zod error. Raw body: {"code":400,"message":"redirect URI https://example.com/cb is not allowed","retryable":false}'
    )
    expect(surfaceOauthError(err)).toBe(
      'Authorization server: redirect URI https://example.com/cb is not allowed'
    )
  })

  it('prefers error_description over message over error in fallback envelope', async () => {
    const { ServerError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js')
    const err = new ServerError(
      'HTTP 400: Invalid OAuth error response: zod. Raw body: {"error":"invalid_grant","error_description":"the description","message":"the message"}'
    )
    expect(surfaceOauthError(err)).toBe('Authorization server: the description')
  })

  it('returns first line of generic errors', () => {
    const err = new Error('Network blip\n  at fetch (...)')
    expect(surfaceOauthError(err)).toBe('Network blip')
  })

  it('truncates messages longer than 250 chars with ellipsis', async () => {
    const { InvalidGrantError } = await import('@modelcontextprotocol/sdk/server/auth/errors.js')
    const longMessage = 'x'.repeat(300)
    const result = surfaceOauthError(new InvalidGrantError(longMessage))
    expect(result.endsWith('…')).toBe(true)
    expect(result.length).toBe(251)
  })

  it('returns generic fallback for non-Error values', () => {
    expect(surfaceOauthError(null)).toBe('Failed to start OAuth flow')
    expect(surfaceOauthError(undefined)).toBe('Failed to start OAuth flow')
  })
})
