/**
 * cost/transcript.ts — pure parser for Claude Code JSONL session transcripts (C11).
 *
 * Claude Code writes one JSON object per line. Assistant turns carry a
 * `message.usage` block of the shape:
 *
 *   {
 *     "type": "assistant",
 *     "session_id": "abc123",                  // optional on some lines
 *     "message": {
 *       "model": "claude-opus-4-6",
 *       "usage": {
 *         "input_tokens": 1234,
 *         "output_tokens": 567,
 *         "cache_read_input_tokens": 0,
 *         "cache_creation_input_tokens": 0
 *       }
 *     },
 *     "timestamp": "2026-04-15T..."
 *   }
 *
 * `parseClaudeCodeTranscript` aggregates per `session_id` and returns one
 * partial CostEntry per session. The caller (CLI / CostTracker) is expected
 * to fill in `taskId`, `costUsd`, `providerId`, and any `--model` overrides
 * before persisting — keeping this module pricing-agnostic and side-effect
 * free (single readFileSync) so it can be exercised by fixture tests with
 * zero environmental noise.
 *
 * Robustness contract:
 *   - Missing file → `[]` (caller decides whether to fail).
 *   - Malformed line → silently skipped (transcripts can be tail-truncated).
 *   - Lines without `message.usage` → ignored (user/system turns).
 *   - Sessions without a `session_id` are bucketed under the sentinel
 *     `"unknown-session"` so they still surface in the aggregate.
 */

import { existsSync, readFileSync } from 'node:fs'
import type { CostEntry } from './types.js'

/**
 * A partial CostEntry as emitted by the transcript parser. `taskId`,
 * `costUsd`, and `providerId` are filled in by the caller (the `tokens`
 * command knows the task context; pricing is applied via `calculateCost`).
 */
export type ParsedTranscriptEntry = Omit<CostEntry, 'taskId' | 'costUsd' | 'providerId'>

interface SessionAccumulator {
	sessionId: string
	modelId: string
	inputTokens: number
	outputTokens: number
	cacheReadTokens: number
	cacheCreationTokens: number
	thinkingTokens: number
	firstTimestamp: string | null
	lastTimestamp: string | null
}

function newAccumulator(sessionId: string, modelId: string): SessionAccumulator {
	return {
		sessionId,
		modelId,
		inputTokens: 0,
		outputTokens: 0,
		cacheReadTokens: 0,
		cacheCreationTokens: 0,
		thinkingTokens: 0,
		firstTimestamp: null,
		lastTimestamp: null,
	}
}

/**
 * Parse a Claude Code JSONL transcript file and aggregate token usage per
 * session_id. Returns one `ParsedTranscriptEntry` per session.
 *
 * Pure: only reads `path` once via readFileSync; no env reads, no cwd.
 */
export function parseClaudeCodeTranscript(path: string): ParsedTranscriptEntry[] {
	if (!existsSync(path)) return []

	let raw: string
	try {
		raw = readFileSync(path, 'utf-8')
	} catch {
		return []
	}

	const sessions = new Map<string, SessionAccumulator>()

	for (const line of raw.split('\n')) {
		if (!line) continue
		let evt: unknown
		try {
			evt = JSON.parse(line)
		} catch {
			continue
		}
		if (!evt || typeof evt !== 'object') continue
		const obj = evt as Record<string, unknown>
		if (obj.type !== 'assistant') continue

		const message = obj.message as Record<string, unknown> | undefined
		if (!message || typeof message !== 'object') continue
		const usage = message.usage as Record<string, unknown> | undefined
		if (!usage || typeof usage !== 'object') continue

		const sessionId =
			(typeof obj.session_id === 'string' && obj.session_id) ||
			(typeof obj.sessionId === 'string' && obj.sessionId) ||
			'unknown-session'
		const modelId = (typeof message.model === 'string' && message.model) || 'unknown-model'
		const timestamp =
			(typeof obj.timestamp === 'string' && obj.timestamp) ||
			(typeof obj.t === 'string' && obj.t) ||
			null

		const acc = sessions.get(sessionId) ?? newAccumulator(sessionId, modelId)
		// Last-seen model wins (sessions rarely switch models mid-flight, but if
		// they do the most recent is the most representative for pricing).
		acc.modelId = modelId
		acc.inputTokens += toNumber(usage.input_tokens)
		acc.outputTokens += toNumber(usage.output_tokens)
		acc.cacheReadTokens += toNumber(usage.cache_read_input_tokens)
		acc.cacheCreationTokens += toNumber(usage.cache_creation_input_tokens)
		// Some transcripts include extended-thinking tokens under
		// `output_tokens_details.thinking_tokens` or top-level; tolerate both.
		const details = usage.output_tokens_details as Record<string, unknown> | undefined
		acc.thinkingTokens +=
			toNumber(usage.thinking_tokens) +
			(details && typeof details === 'object' ? toNumber(details.thinking_tokens) : 0)

		if (timestamp) {
			if (!acc.firstTimestamp) acc.firstTimestamp = timestamp
			acc.lastTimestamp = timestamp
		}
		sessions.set(sessionId, acc)
	}

	const out: ParsedTranscriptEntry[] = []
	for (const acc of sessions.values()) {
		const ts = acc.firstTimestamp ?? acc.lastTimestamp ?? new Date(0).toISOString()
		out.push({
			sessionId: acc.sessionId,
			modelId: acc.modelId,
			inputTokens: acc.inputTokens,
			outputTokens: acc.outputTokens,
			cacheReadTokens: acc.cacheReadTokens,
			cacheCreationTokens: acc.cacheCreationTokens,
			thinkingTokens: acc.thinkingTokens,
			timestamp: ts,
			durationMs: durationBetween(acc.firstTimestamp, acc.lastTimestamp),
		})
	}
	return out
}

function toNumber(v: unknown): number {
	if (typeof v === 'number' && Number.isFinite(v)) return v
	return 0
}

function durationBetween(first: string | null, last: string | null): number {
	if (!first || !last) return 0
	const a = Date.parse(first)
	const b = Date.parse(last)
	if (Number.isNaN(a) || Number.isNaN(b)) return 0
	return Math.max(0, b - a)
}
