/**
 * Timeout Strategy Integration Tests
 * Verifies layered timeout architecture (H-1, M-6, L-3)
 */

import { describe, it, expect, vi } from 'vitest'
import { z } from 'zod'
import { composeSignal } from '../llm/adapters/server/compose-signal'
import { executeToolCall } from '../agent/actions/executor'
import { Agent } from '../agent/core/agent'
import { AgentTimeoutError, AgentStalledError } from '../agent/errors'
import type { Tool } from '../tools/types'
import type { IterationContext, LLMAdapterFactory } from '../agent/core/types'
import type { ExecutionContext } from '../base/types'
import { createMockExecutionContext, createMockTool } from '../test-utils/mocks'
import { MockAdapter } from '../llm/adapters/mock-adapter'

/**
 * Create a mock LLMAdapterFactory that returns a MockAdapter instance.
 * Used to satisfy the Agent constructor's second parameter.
 */
function createMockAdapterFactory(): LLMAdapterFactory {
  const adapter = new MockAdapter()
  return () => adapter
}

// ============================================================================
// Test Helpers
// ============================================================================

function createMockIterationContext(overrides?: {
  tools?: Map<string, Tool>
  executionContext?: Partial<ExecutionContext>
}): IterationContext {
  const memoryHistory: unknown[] = []
  const mockMemoryManager = {
    addToHistory: vi.fn((entry: unknown) => memoryHistory.push(entry)),
    getHistory: vi.fn(() => memoryHistory),
    getHistoryLength: vi.fn(() => memoryHistory.length),
    estimateTokens: vi.fn(() => 0),
    clear: vi.fn(),
    toContext: vi.fn(() => '')
  }

  const executionContext: ExecutionContext = {
    executionId: 'test-exec-123',
    organizationId: 'test-org-123',
    organizationName: 'test-org',
    userId: 'test-user-123',
    resourceId: 'test-resource-123',
    executionDepth: 0,
    store: new Map(),
    logger: {
      info: vi.fn(),
      error: vi.fn(),
      warn: vi.fn(),
      debug: vi.fn()
    },
    ...overrides?.executionContext
  }

  return {
    executionContext,
    toolRegistry: overrides?.tools ?? new Map(),
    config: {
      type: 'agent' as const,
      resourceId: 'test-agent',
      name: 'Test Agent',
      description: 'Test',
      environment: 'dev',
      systemPrompt: 'Test'
    },
    contract: {
      inputSchema: z.object({}),
      outputSchema: z.object({})
    },
    logger: {
      action: vi.fn(),
      reasoning: vi.fn(),
      lifecycle: vi.fn(),
      toolCall: vi.fn(),
      info: vi.fn(),
      error: vi.fn(),
      warn: vi.fn(),
      debug: vi.fn()
    },
    modelConfig: {
      provider: 'mock' as const,
      model: 'mock',
      apiKey: 'test'
    },
    memoryManager: mockMemoryManager,
    iteration: 1
  } as IterationContext
}

/** Create a tool that hangs forever (never resolves) */
function createHangingTool(name: string, timeout?: number): Tool {
  return {
    name,
    description: `Hanging tool: ${name}`,
    inputSchema: z.object({ input: z.string() }),
    outputSchema: z.object({ output: z.string() }),
    execute: () => new Promise(() => {}), // Never resolves
    timeout
  }
}

/** Create a tool that takes a specified duration to complete */
function createSlowTool(name: string, durationMs: number, timeout?: number): Tool {
  return {
    name,
    description: `Slow tool: ${name}`,
    inputSchema: z.object({ input: z.string() }),
    outputSchema: z.object({ output: z.string() }),
    execute: async () => {
      await new Promise((resolve) => setTimeout(resolve, durationMs))
      return { output: 'completed' }
    },
    timeout
  }
}

// ============================================================================
// Layer 0: composeSignal() utility
// ============================================================================

describe('composeSignal', () => {
  it('returns signal that fires on timeout', async () => {
    const signal = composeSignal(50) // 50ms timeout

    expect(signal.aborted).toBe(false)
    await new Promise((resolve) => setTimeout(resolve, 100))
    expect(signal.aborted).toBe(true)
  })

  it('returns signal that fires on execution signal abort', () => {
    const controller = new AbortController()
    const signal = composeSignal(60_000, controller.signal) // Long timeout

    expect(signal.aborted).toBe(false)
    controller.abort('timeout')
    expect(signal.aborted).toBe(true)
  })

  it('fires whichever comes first (timeout wins)', async () => {
    const controller = new AbortController()
    const signal = composeSignal(50, controller.signal) // 50ms timeout

    await new Promise((resolve) => setTimeout(resolve, 100))
    expect(signal.aborted).toBe(true)
    // Controller was never aborted, but the composed signal is
    expect(controller.signal.aborted).toBe(false)
  })

  it('fires whichever comes first (execution signal wins)', () => {
    const controller = new AbortController()
    const signal = composeSignal(60_000, controller.signal) // Long timeout

    controller.abort('timeout')
    expect(signal.aborted).toBe(true)
  })

  it('works without execution signal', async () => {
    const signal = composeSignal(50)

    await new Promise((resolve) => setTimeout(resolve, 100))
    expect(signal.aborted).toBe(true)
  })
})

// ============================================================================
// Layer 1: Tool Executor Timeout
// ============================================================================

describe('Tool Executor Timeout', () => {
  it('times out a hanging tool with custom timeout', async () => {
    const tool = createHangingTool('slow_api', 100) // 100ms timeout
    const toolRegistry = new Map<string, Tool>([['slow_api', tool]])
    const iterationContext = createMockIterationContext({ tools: toolRegistry })

    await executeToolCall(iterationContext, {
      type: 'tool-call',
      id: 'call-1',
      name: 'slow_api',
      input: { input: 'test' }
    })

    // Verify error was added to memory (not thrown - executor catches and stores as 'error' type)
    expect(iterationContext.memoryManager.addToHistory).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'error',
        content: expect.stringContaining('timed out')
      })
    )
  })

  it('tool timeout error message includes tool name', async () => {
    const tool = createHangingTool('notion_search', 100)
    const toolRegistry = new Map<string, Tool>([['notion_search', tool]])
    const iterationContext = createMockIterationContext({ tools: toolRegistry })

    await executeToolCall(iterationContext, {
      type: 'tool-call',
      id: 'call-1',
      name: 'notion_search',
      input: { input: 'test' }
    })

    // Check the logger recorded the timeout with tool name
    expect(iterationContext.logger.toolCall).toHaveBeenCalledWith(
      'notion_search',
      expect.any(Number),
      expect.any(Number),
      expect.any(Number),
      expect.any(Number),
      false,
      expect.stringContaining('notion_search'),
      expect.anything(),
      undefined
    )
  })

  it('succeeds when tool completes before timeout', async () => {
    const tool = createSlowTool('fast_api', 10, 500) // 10ms execution, 500ms timeout
    const toolRegistry = new Map<string, Tool>([['fast_api', tool]])
    const iterationContext = createMockIterationContext({ tools: toolRegistry })

    await executeToolCall(iterationContext, {
      type: 'tool-call',
      id: 'call-1',
      name: 'fast_api',
      input: { input: 'test' }
    })

    // Should have a success result, not an error
    expect(iterationContext.memoryManager.addToHistory).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'tool-result',
        content: expect.stringContaining('completed')
      })
    )
  })

  it('uses default 30min timeout when tool has no custom timeout', () => {
    const tool = createMockTool('default_tool')
    expect(tool.timeout).toBeUndefined()
    // The executor uses `tool.timeout ?? DEFAULT_TOOL_TIMEOUT` (1_800_000 / 30min)
    // We verify the field is optional and defaults are handled in the executor, not the tool definition
  })

  it('execution signal aborts tool immediately', async () => {
    // Tool that hangs forever with a long per-tool timeout
    const tool: Tool = {
      name: 'slow_tool',
      description: 'Hangs forever',
      inputSchema: z.object({ input: z.string() }),
      outputSchema: z.object({ output: z.string() }),
      execute: () => new Promise(() => {}),
      timeout: 60_000 // 60s per-tool timeout
    }
    const toolRegistry = new Map<string, Tool>([['slow_tool', tool]])

    // Create an AbortController that fires quickly (simulating execution ceiling)
    const controller = new AbortController()
    setTimeout(() => controller.abort('timeout'), 50) // 50ms ceiling

    const iterationContext = createMockIterationContext({
      tools: toolRegistry,
      executionContext: { signal: controller.signal }
    })

    await executeToolCall(iterationContext, {
      type: 'tool-call',
      id: 'call-1',
      name: 'slow_tool',
      input: { input: 'test' }
    })

    // Tool should have been aborted by the execution signal, not the per-tool timeout
    expect(iterationContext.memoryManager.addToHistory).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'error',
        content: expect.stringContaining('timed out')
      })
    )
  })

  it('passes signal to tool execute options', async () => {
    const executeSpy = vi.fn(async () => ({ output: 'done' }))
    const tool: Tool = {
      name: 'signal_aware_tool',
      description: 'Checks signal',
      inputSchema: z.object({ input: z.string() }),
      outputSchema: z.object({ output: z.string() }),
      execute: executeSpy,
      timeout: 5_000
    }
    const toolRegistry = new Map<string, Tool>([['signal_aware_tool', tool]])
    const iterationContext = createMockIterationContext({ tools: toolRegistry })

    await executeToolCall(iterationContext, {
      type: 'tool-call',
      id: 'call-1',
      name: 'signal_aware_tool',
      input: { input: 'test' }
    })

    // Verify signal was passed to tool.execute
    expect(executeSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        signal: expect.any(AbortSignal)
      })
    )
  })
})

// ============================================================================
// Layer 2: Agent Execution Ceiling (between-iteration check)
// ============================================================================

describe('Agent Execution Ceiling', () => {
  it('throws AgentTimeoutError when signal is aborted with timeout reason', async () => {
    const controller = new AbortController()
    controller.abort('timeout') // Pre-abort with timeout reason

    const definition = {
      config: {
        type: 'agent' as const,
        resourceId: 'test-agent',
        name: 'Test Agent',
        description: 'Test',
        environment: 'dev' as const,
        systemPrompt: 'Test',
        constraints: { timeout: 5000 }
      },
      contract: {
        inputSchema: z.object({ query: z.string() }),
        outputSchema: z.object({ response: z.string(), processedAt: z.string() })
      },
      tools: [],
      modelConfig: {
        model: 'mock' as const,
        provider: 'mock',
        apiKey: 'test-key'
      }
    }

    const agent = new Agent(definition, createMockAdapterFactory())
    const context = createMockExecutionContext({ signal: controller.signal })

    await expect(agent.execute({ query: 'test' }, context)).rejects.toThrow(AgentTimeoutError)
  })

  it('throws AgentTimeoutError with timeout and iteration context', async () => {
    const controller = new AbortController()
    controller.abort('timeout')

    const definition = {
      config: {
        type: 'agent' as const,
        resourceId: 'test-agent',
        name: 'Test Agent',
        description: 'Test',
        environment: 'dev' as const,
        systemPrompt: 'Test',
        constraints: { timeout: 30000 }
      },
      contract: {
        inputSchema: z.object({ query: z.string() }),
        outputSchema: z.object({ response: z.string(), processedAt: z.string() })
      },
      tools: [],
      modelConfig: {
        model: 'mock' as const,
        provider: 'mock',
        apiKey: 'test-key'
      }
    }

    const agent = new Agent(definition, createMockAdapterFactory())
    const context = createMockExecutionContext({ signal: controller.signal })

    try {
      await agent.execute({ query: 'test' }, context)
      expect.unreachable('Should have thrown')
    } catch (error) {
      expect(error).toBeInstanceOf(AgentTimeoutError)
      const timeoutError = error as AgentTimeoutError
      expect(timeoutError.message).toContain('30000ms')
      expect(timeoutError.context).toEqual({ timeout: 30000, iteration: 1 })
    }
  })

  it('throws AgentIterationError for non-timeout abort (user cancellation)', async () => {
    const controller = new AbortController()
    controller.abort('user_cancelled') // Non-timeout reason

    const definition = {
      config: {
        type: 'agent' as const,
        resourceId: 'test-agent',
        name: 'Test Agent',
        description: 'Test',
        environment: 'dev' as const,
        systemPrompt: 'Test'
      },
      contract: {
        inputSchema: z.object({ query: z.string() }),
        outputSchema: z.object({ response: z.string(), processedAt: z.string() })
      },
      tools: [],
      modelConfig: {
        model: 'mock' as const,
        provider: 'mock',
        apiKey: 'test-key'
      }
    }

    const agent = new Agent(definition, createMockAdapterFactory())
    const context = createMockExecutionContext({ signal: controller.signal })

    try {
      await agent.execute({ query: 'test' }, context)
      expect.unreachable('Should have thrown')
    } catch (error) {
      expect(error).not.toBeInstanceOf(AgentTimeoutError)
      expect((error as Error).message).toContain('cancelled')
    }
  })

  it('executes normally when no signal is set', async () => {
    const definition = {
      config: {
        type: 'agent' as const,
        resourceId: 'test-agent',
        name: 'Test Agent',
        description: 'Test',
        environment: 'dev' as const,
        systemPrompt: 'Test'
      },
      contract: {
        inputSchema: z.object({ query: z.string() }),
        outputSchema: z.object({ response: z.string(), processedAt: z.string() })
      },
      tools: [],
      modelConfig: {
        model: 'mock' as const,
        provider: 'mock',
        apiKey: 'test-key'
      }
    }

    const agent = new Agent(definition, createMockAdapterFactory())
    const context = createMockExecutionContext()

    const output = await agent.execute({ query: 'test' }, context)
    expect(output).toBeDefined()
    expect(output.response).toBe('Mock response')
  })

  it('throws AgentStalledError when signal is aborted with stalled reason', async () => {
    const controller = new AbortController()
    controller.abort('stalled')

    const definition = {
      config: {
        type: 'agent' as const,
        resourceId: 'test-agent',
        name: 'Test Agent',
        description: 'Test',
        environment: 'dev' as const,
        systemPrompt: 'Test'
      },
      contract: {
        inputSchema: z.object({ query: z.string() }),
        outputSchema: z.object({ response: z.string(), processedAt: z.string() })
      },
      tools: [],
      modelConfig: {
        model: 'mock' as const,
        provider: 'mock',
        apiKey: 'test-key'
      }
    }

    const agent = new Agent(definition, createMockAdapterFactory())
    const context = createMockExecutionContext({ signal: controller.signal })

    try {
      await agent.execute({ query: 'test' }, context)
      expect.unreachable('Should have thrown')
    } catch (error) {
      expect(error).toBeInstanceOf(AgentStalledError)
      const stalledError = error as AgentStalledError
      expect(stalledError.message).toContain('stalled')
      expect(stalledError.context).toEqual({ iteration: 1 })
    }
  })

  it('calls onHeartbeat per iteration when provided', async () => {
    const heartbeatSpy = vi.fn(async () => {})

    const definition = {
      config: {
        type: 'agent' as const,
        resourceId: 'test-agent',
        name: 'Test Agent',
        description: 'Test',
        environment: 'dev' as const,
        systemPrompt: 'Test'
      },
      contract: {
        inputSchema: z.object({ query: z.string() }),
        outputSchema: z.object({ response: z.string(), processedAt: z.string() })
      },
      tools: [],
      modelConfig: {
        model: 'mock' as const,
        provider: 'mock',
        apiKey: 'test-key'
      }
    }

    const agent = new Agent(definition, createMockAdapterFactory())
    const context = createMockExecutionContext({ onHeartbeat: heartbeatSpy })

    await agent.execute({ query: 'test' }, context)
    expect(heartbeatSpy).toHaveBeenCalled()
  })

  it('continues execution when onHeartbeat throws', async () => {
    const heartbeatSpy = vi.fn(async () => {
      throw new Error('heartbeat write failed')
    })

    const definition = {
      config: {
        type: 'agent' as const,
        resourceId: 'test-agent',
        name: 'Test Agent',
        description: 'Test',
        environment: 'dev' as const,
        systemPrompt: 'Test'
      },
      contract: {
        inputSchema: z.object({ query: z.string() }),
        outputSchema: z.object({ response: z.string(), processedAt: z.string() })
      },
      tools: [],
      modelConfig: {
        model: 'mock' as const,
        provider: 'mock',
        apiKey: 'test-key'
      }
    }

    const agent = new Agent(definition, createMockAdapterFactory())
    const context = createMockExecutionContext({ onHeartbeat: heartbeatSpy })

    const output = await agent.execute({ query: 'test' }, context)
    expect(output).toBeDefined()
    expect(heartbeatSpy).toHaveBeenCalled()
  })
})
