import { describe, expect, it } from 'vitest'

import { extractLabelFacts } from '../labels/label-schema.js'
import type { GiteaIssue } from '../gitea/types.js'
import type { FetchLike } from '../gitea/types.js'
import { type ChatMessage, type CompletionResult, createChatClient, type ToolDecl } from './chat-client.js'
import { runAgentTurn } from './agent-loop.js'
import { pickModel } from './model-router.js'
import { buildProjectView, type ProjectSnapshot } from './query-project.js'

// ---- chat client ----

describe('createChatClient', () => {
  function stub(body: unknown) {
    const calls: { url: string; body: unknown }[] = []
    const fetch: FetchLike = (url, init) => {
      calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined })
      return Promise.resolve({
        ok: true,
        status: 200,
        json: () => Promise.resolve(body),
        text: () => Promise.resolve(''),
      })
    }
    return { fetch, calls }
  }

  it('POSTs to /chat/completions and parses content', async () => {
    const { fetch, calls } = stub({ choices: [{ message: { content: 'the focus is #2' } }] })
    const client = createChatClient({ baseUrl: 'http://localhost:1234/v1', model: 'gemma' }, fetch)
    const res = await client.complete([{ role: 'user', content: 'what now?' }])

    expect(res.content).toBe('the focus is #2')
    expect(res.toolCalls).toEqual([])
    expect(calls[0].url).toBe('http://localhost:1234/v1/chat/completions')
    expect((calls[0].body as { model: string }).model).toBe('gemma')
  })

  it('parses tool calls from the wire shape', async () => {
    const { fetch } = stub({
      choices: [
        {
          message: {
            content: null,
            tool_calls: [{ id: 'c1', function: { name: 'query_project', arguments: '{"view":"focus"}' } }],
          },
        },
      ],
    })
    const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
    const res = await client.complete([{ role: 'user', content: 'x' }], [{ name: 'query_project', description: '', parameters: {} }])
    expect(res.toolCalls).toEqual([{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }])
  })

  it('serializes assistant tool_calls + tool results in the request', async () => {
    const { fetch, calls } = stub({ choices: [{ message: { content: 'ok' } }] })
    const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
    const convo: ChatMessage[] = [
      { role: 'user', content: 'hi' },
      { role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] },
      { role: 'tool', toolCallId: 'c1', name: 'query_project', content: '{"now":null}' },
    ]
    await client.complete(convo)
    const sent = (calls[0].body as { messages: Record<string, unknown>[] }).messages
    expect(sent[1].tool_calls).toBeDefined()
    expect(sent[2]).toEqual({ role: 'tool', tool_call_id: 'c1', content: '{"now":null}' })
  })
})

// ---- model router ----

describe('pickModel', () => {
  const router = { small: { baseUrl: 'u', model: 'small' }, big: { baseUrl: 'u', model: 'big' } }
  it('routes plan → big, everything else → small', () => {
    expect(pickModel(router, 'plan').model).toBe('big')
    expect(pickModel(router, 'ritual').model).toBe('small')
  })
})

// ---- agent loop ----

describe('runAgentTurn', () => {
  const tools: ToolDecl[] = [{ name: 'query_project', description: '', parameters: {} }]

  it('runs a tool then returns the model prose, recording the step', async () => {
    // first completion asks for a tool; second answers in prose
    const scripted: CompletionResult[] = [
      { content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }] },
      { content: 'Right now: #2.', toolCalls: [] },
    ]
    let i = 0
    const executed: { name: string; args: unknown }[] = []
    const turn = await runAgentTurn({
      complete: async () => scripted[i++],
      messages: [{ role: 'user', content: 'what now?' }],
      tools,
      execute: async (name, args) => {
        executed.push({ name, args })
        return { now: { issue: 2 } }
      },
    })

    expect(turn.content).toBe('Right now: #2.')
    expect(turn.steps).toEqual([
      { tool: 'query_project', arguments: '{"view":"focus"}', result: '{"now":{"issue":2}}' },
    ])
    expect(executed).toEqual([{ name: 'query_project', args: { view: 'focus' } }])
    // conversation carries user → assistant(tool) → tool → assistant(prose)
    expect(turn.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant'])
  })

  it('answers directly when the model needs no tool', async () => {
    const turn = await runAgentTurn({
      complete: async () => ({ content: 'Hello.', toolCalls: [] }),
      messages: [{ role: 'user', content: 'hi' }],
      tools,
      execute: async () => ({}),
    })
    expect(turn.content).toBe('Hello.')
    expect(turn.steps).toEqual([])
  })

  it('captures a tool executor error as the tool result instead of throwing', async () => {
    const scripted: CompletionResult[] = [
      { content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] },
      { content: 'Something went wrong reading that.', toolCalls: [] },
    ]
    let i = 0
    const turn = await runAgentTurn({
      complete: async () => scripted[i++],
      messages: [{ role: 'user', content: 'x' }],
      tools,
      execute: async () => {
        throw new Error('boom')
      },
    })
    expect(turn.steps[0].result).toContain('boom')
    expect(turn.content).toBe('Something went wrong reading that.')
  })

  it('forces a final prose answer when the step budget is exhausted', async () => {
    // always asks for a tool; the loop must still terminate with prose
    let toolRounds = 0
    const turn = await runAgentTurn({
      complete: async (_m, tls) => {
        if (tls && tls.length) {
          toolRounds++
          return { content: '', toolCalls: [{ id: `c${toolRounds}`, name: 'query_project', arguments: '{}' }] }
        }
        return { content: 'final answer', toolCalls: [] } // called with tools withheld
      },
      messages: [{ role: 'user', content: 'x' }],
      tools,
      execute: async () => ({}),
      maxSteps: 2,
    })
    expect(toolRounds).toBe(2)
    expect(turn.content).toBe('final answer')
  })
})

// ---- query_project view builder ----

describe('buildProjectView', () => {
  const asOf = new Date('2026-02-01T00:00:00Z')

  function issue(over: Partial<GiteaIssue>): GiteaIssue {
    const labels = over.labels ?? []
    return {
      number: 1,
      title: '#1',
      body: '',
      state: 'open',
      labels,
      facts: extractLabelFacts(labels),
      milestone: null,
      assignee: null,
      assignees: [],
      createdAt: '2026-01-05T09:00:00Z',
      updatedAt: '2026-01-05T09:00:00Z',
      closedAt: null,
      url: '',
      ...over,
    }
  }

  const snap: ProjectSnapshot = {
    issues: [
      issue({ number: 2, title: 'ChangeSource', labels: ['est/3d', 'p/1'] }),
      issue({ number: 3, title: 'SQLite cache', labels: ['est/2d', 'p/1'] }),
      issue({ number: 9, title: 'Done thing', state: 'closed', labels: ['est/2d'], closedAt: '2026-01-12T09:00:00Z' }),
    ],
    timelines: { 9: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }] },
    deps: [{ issue: 3, dependsOn: 2 }],
  }

  it('focus returns Now/Next/Later grounded in the scheduler', () => {
    const v = buildProjectView('focus', undefined, snap, asOf) as {
      now: { issue: number } | null
      openCount: number
    }
    expect(v.now?.issue).toBe(2) // #3 depends on #2, so #2 comes first
    expect(v.openCount).toBe(2)
  })

  it('board groups issues by lifecycle column', () => {
    const v = buildProjectView('board', undefined, snap, asOf) as {
      columns: { column: string; count: number }[]
    }
    const done = v.columns.find((c) => c.column === 'done')
    expect(done?.count).toBe(1) // #9
    const triage = v.columns.find((c) => c.column === 'triage')
    expect(triage?.count).toBe(2) // #2, #3 labelled, no commits yet
  })

  it('calibration reports n + cold-start honestly', () => {
    const v = buildProjectView('calibration', undefined, snap, asOf) as { n: number; coldStart: boolean }
    expect(v.n).toBe(1) // one closed, estimated, with an actual
    expect(v.coldStart).toBe(true)
  })

  it('issue returns intent + derived for a specific id', () => {
    const v = buildProjectView('issue', { issueId: 3 }, snap, asOf) as {
      issue: number
      blockedBy: number[]
    }
    expect(v.issue).toBe(3)
    expect(v.blockedBy).toEqual([2])
  })

  it('search matches title/labels', () => {
    const v = buildProjectView('search', { query: 'sqlite' }, snap, asOf) as { results: { issue: number }[] }
    expect(v.results.map((r) => r.issue)).toEqual([3])
  })

  it('unbuilt views return a notImplemented marker, not fabricated data', () => {
    expect(buildProjectView('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' })
  })
})
