package acp

import (
	"context"
	"encoding/json"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func msgWithResult(t *testing.T, result string) *Message {
	t.Helper()
	return &Message{JSONRPC: "2.0", ID: json.RawMessage(`"client-prompt-1"`), Result: json.RawMessage(result)}
}

func msgNotification(t *testing.T, method, params string) *Message {
	t.Helper()
	return &Message{JSONRPC: "2.0", Method: method, Params: json.RawMessage(params)}
}

func TestExtractUsage(t *testing.T) {
	tests := []struct {
		name       string
		msg        *Message
		wantHas    bool
		wantPrompt int
		wantComp   int
		wantThink  int
		wantCost   *float64
	}{
		{
			name:       "snake_case prompt response",
			msg:        msgWithResult(t, `{"stopReason":"end_turn","usage":{"input_tokens":120,"output_tokens":48}}`),
			wantHas:    true,
			wantPrompt: 120,
			wantComp:   48,
		},
		{
			name:       "camelCase v2 usage with thoughtTokens + cached",
			msg:        msgWithResult(t, `{"stopReason":"end_turn","usage":{"inputTokens":200,"outputTokens":90,"thoughtTokens":15,"cachedReadTokens":50}}`),
			wantHas:    true,
			wantPrompt: 200,
			wantComp:   90,
			wantThink:  15,
		},
		{
			name:     "usage_update notification with cost only",
			msg:      msgNotification(t, "session/update", `{"update":{"sessionUpdate":"usage_update","used":1000,"size":200000,"cost":{"amount":0.0123,"currency":"USD"}}}`),
			wantHas:  true,
			wantCost: f64ptr(0.0123),
		},
		{
			name:    "usage_update without cost -> no usage (gauge ignored)",
			msg:     msgNotification(t, "session/update", `{"update":{"sessionUpdate":"usage_update","used":1000,"size":200000}}`),
			wantHas: false,
		},
		{
			name:    "ordinary agent_message_chunk -> no usage",
			msg:     msgNotification(t, "session/update", `{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}}`),
			wantHas: false,
		},
		{
			name:    "malformed result -> no panic, no usage",
			msg:     msgWithResult(t, `{"usage":not-json`),
			wantHas: false,
		},
		{
			name:    "result without usage object -> no usage",
			msg:     msgWithResult(t, `{"stopReason":"end_turn"}`),
			wantHas: false,
		},
		{
			name:    "nil message -> no usage",
			msg:     nil,
			wantHas: false,
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got := ExtractUsage(tc.msg)
			assert.Equal(t, tc.wantHas, got.HasUsage)
			if !tc.wantHas {
				return
			}
			assert.Equal(t, tc.wantPrompt, got.PromptTokens)
			assert.Equal(t, tc.wantComp, got.CompletionTokens)
			assert.Equal(t, tc.wantThink, got.ThinkingTokens)
			if tc.wantCost == nil {
				assert.Nil(t, got.CostUSD)
			} else {
				require.NotNil(t, got.CostUSD)
				assert.InDelta(t, *tc.wantCost, *got.CostUSD, 1e-9)
			}
		})
	}
}

func f64ptr(f float64) *float64 { return &f }
func i64ptr(i int64) *int64     { return &i }

// ===== usageAccumulator =====

type fakeUsageRepo struct {
	ledger      []*SessionUsageRecord
	dedup       map[int64]struct{}
	totPrompt   int64
	totComp     int64
	totThink    int64
	totCost     float64
	projectCost float64
}

func (f *fakeUsageRepo) InsertSessionUsage(_ context.Context, r *SessionUsageRecord) (bool, error) {
	if r.SourceEventID != nil {
		if f.dedup == nil {
			f.dedup = map[int64]struct{}{}
		}
		if _, ok := f.dedup[*r.SourceEventID]; ok {
			return false, nil
		}
		f.dedup[*r.SourceEventID] = struct{}{}
	}
	cp := *r
	f.ledger = append(f.ledger, &cp)
	return true, nil
}

func (f *fakeUsageRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) {
	f.totPrompt += dp
	f.totComp += dc
	f.totThink += dt
	f.totCost += dCost
	return f.totPrompt, f.totComp, f.totThink, f.totCost, nil
}

func (f *fakeUsageRepo) SumProjectCostUSD(_ context.Context, _ uuid.UUID, _ time.Time) (float64, error) {
	return f.projectCost, nil
}

type fakePriceLookup struct {
	price ModelPrice
}

func (f fakePriceLookup) PriceForModel(_ context.Context, _ uuid.UUID) (ModelPrice, error) {
	return f.price, nil
}

func TestAccumulator_PricesViaModelLookup(t *testing.T) {
	repo := &fakeUsageRepo{}
	mid := uuid.New()
	acc := newUsageAccumulator(AccumulatorParams{
		Repo:    repo,
		Prices:  fakePriceLookup{price: ModelPrice{PromptPerM: 3, CompletionPerM: 15, ThinkingPerM: 0, Found: true}},
		ModelID: &mid,
		Caps:    BudgetCaps{},
	})
	_, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, HasUsage: true}, 1)
	assert.False(t, breached)
	require.Len(t, repo.ledger, 1)
	// 3 USD (prompt) + 15 USD (completion) = 18.
	assert.InDelta(t, 18.0, repo.ledger[0].CostUSD, 1e-6)
	assert.InDelta(t, 18.0, repo.totCost, 1e-6)
}

func TestAccumulator_FallsBackToAgentCost(t *testing.T) {
	repo := &fakeUsageRepo{}
	acc := newUsageAccumulator(AccumulatorParams{Repo: repo}) // no model price
	cost := 0.5
	_, _ = acc.Observe(context.Background(), ParsedUsage{CostUSD: &cost, HasUsage: true}, 1)
	require.Len(t, repo.ledger, 1)
	assert.InDelta(t, 0.5, repo.ledger[0].CostUSD, 1e-9)
}

func TestAccumulator_SkipsWhenNoUsage(t *testing.T) {
	repo := &fakeUsageRepo{}
	acc := newUsageAccumulator(AccumulatorParams{Repo: repo})
	_, breached := acc.Observe(context.Background(), ParsedUsage{HasUsage: false}, 1)
	assert.False(t, breached)
	assert.Empty(t, repo.ledger)
}

func TestAccumulator_DedupBySourceEventID(t *testing.T) {
	repo := &fakeUsageRepo{}
	mid := uuid.New()
	acc := newUsageAccumulator(AccumulatorParams{
		Repo:    repo,
		Prices:  fakePriceLookup{price: ModelPrice{CompletionPerM: 10, Found: true}},
		ModelID: &mid,
	})
	p := ParsedUsage{CompletionTokens: 1000, HasUsage: true}
	_, _ = acc.Observe(context.Background(), p, 7)
	_, _ = acc.Observe(context.Background(), p, 7) // same event id -> no-op
	assert.Len(t, repo.ledger, 1)
	assert.InDelta(t, 0.01, repo.totCost, 1e-9)
}

func TestAccumulator_BreachCost(t *testing.T) {
	repo := &fakeUsageRepo{}
	mid := uuid.New()
	acc := newUsageAccumulator(AccumulatorParams{
		Repo:    repo,
		Prices:  fakePriceLookup{price: ModelPrice{CompletionPerM: 1000, Found: true}},
		ModelID: &mid,
		Caps:    BudgetCaps{MaxCostUSD: f64ptr(0.5)},
	})
	// 1000 completion tokens * 1000/M = 1.0 USD >= 0.5 cap.
	reason, breached := acc.Observe(context.Background(), ParsedUsage{CompletionTokens: 1000, HasUsage: true}, 1)
	assert.True(t, breached)
	assert.Equal(t, BreachCost, reason)
}

func TestAccumulator_BreachTokens(t *testing.T) {
	repo := &fakeUsageRepo{}
	acc := newUsageAccumulator(AccumulatorParams{
		Repo: repo,
		Caps: BudgetCaps{MaxTokens: i64ptr(1000)},
	})
	reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 600, CompletionTokens: 600, HasUsage: true}, 1)
	assert.True(t, breached)
	assert.Equal(t, BreachTokens, reason)
}

func TestAccumulator_BreachWallClock(t *testing.T) {
	repo := &fakeUsageRepo{}
	start := time.Now().Add(-2 * time.Hour)
	acc := newUsageAccumulator(AccumulatorParams{
		Repo:      repo,
		StartedAt: start,
		Caps:      BudgetCaps{MaxWallClockSeconds: i32ptr(60)}, // 1 minute cap, 2h elapsed
	})
	reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1, HasUsage: true}, 1)
	assert.True(t, breached)
	assert.Equal(t, BreachWallClock, reason)
}

func TestAccumulator_BreachProjectBudget(t *testing.T) {
	repo := &fakeUsageRepo{projectCost: 100} // already over the project cap
	acc := newUsageAccumulator(AccumulatorParams{
		Repo:             repo,
		ProjectBudgetUSD: 50,
	})
	reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1, HasUsage: true}, 1)
	assert.True(t, breached)
	assert.Equal(t, BreachCost, reason)
}

func TestAccumulator_NoBreachUnderCaps(t *testing.T) {
	repo := &fakeUsageRepo{}
	acc := newUsageAccumulator(AccumulatorParams{
		Repo: repo,
		Caps: BudgetCaps{MaxTokens: i64ptr(1_000_000), MaxCostUSD: f64ptr(100)},
	})
	_, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 10, CompletionTokens: 10, HasUsage: true}, 1)
	assert.False(t, breached)
}

func i32ptr(i int32) *int32 { return &i }
