package main

import (
	"fmt"
	"os"

	"gopkg.in/yaml.v3"
)

// conversation is one golden conversation from a manifest.
type conversation struct {
	Name       string     `yaml:"name"`
	Prompt     string     `yaml:"prompt"`
	Followup   string     `yaml:"followup"`   // backward compat: single followup
	Followups  []string   `yaml:"followups"`  // P5: multi-turn followups
	Assertions []assertion `yaml:"assertions"`
}

// followups returns the full list of follow-up messages, supporting both
// the single `followup` field (backward compat) and the multi-turn
// `followups` list.
func (c conversation) followups() []string {
	if len(c.Followups) > 0 {
		return c.Followups
	}
	if c.Followup != "" {
		return []string{c.Followup}
	}
	return nil
}

// assertion is one check against the final transcript. The `kind` field
// selects the scorer; the rest are scorer-specific parameters.
//
// Supported kinds:
//
//	completes             — session status reached done/failed (not stuck executing)
//	outcome_is            — session outcome == value (success/failure/partial)
//	no_propose_plan       — propose_plan was never called
//	proposes_plan         — propose_plan called >= 1 time (plan-always model; P1)
//	proposes_plan_once    — propose_plan was called exactly once
//	no_duplicate_proposal — propose_plan called at most once
//	plan_before_run       — the first `run` call comes after the first `propose_plan` (P1 ordering gate)
//	plan_generations      — the persisted plan has exactly `value` distinct generations (P2 iteration: 1 = single, 2 = one followup)
//	writes_back           — update_entity_attributes or create_relationship was called
//	max_tool_calls        — total tool calls <= value
//	max_run_calls         — total `run` calls <= value
//	no_run                — `run` was never called
//	calls_tool            — the named tool appears in the transcript
//	plan_step_count       — the plan has exactly `value` steps
//	no_duplicate_complete — complete_task called at most once
type assertion struct {
	Kind  string `yaml:"kind"`
	Value any    `yaml:"value"`
}

// loadManifest reads a YAML file containing a list of conversations.
func loadManifest(path string) ([]conversation, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var convs []conversation
	if err := yaml.Unmarshal(b, &convs); err != nil {
		return nil, fmt.Errorf("parse %s: %w", path, err)
	}
	return convs, nil
}

// scoreAssertions evaluates each assertion against the transcript + session.
func scoreAssertions(asserts []assertion, t transcript, s sessionState) []assertionResult {
	out := make([]assertionResult, 0, len(asserts))
	for _, a := range asserts {
		r := assertionResult{Name: a.Kind}
		r.Passed, r.Detail = scoreOne(a, t, s)
		if !r.Passed && r.Detail == "" {
			r.Detail = "assertion failed"
		}
		out = append(out, r)
	}
	return out
}

func scoreOne(a assertion, t transcript, s sessionState) (bool, string) {
	tools := t.toolNames()
	switch a.Kind {
	case "completes":
		if s.Status == "done" || s.Status == "failed" {
			return true, fmt.Sprintf("status=%s", s.Status)
		}
		return false, fmt.Sprintf("status=%s (not terminal)", s.Status)

	case "outcome_is":
		want, _ := a.Value.(string)
		if s.Outcome == want {
			return true, fmt.Sprintf("outcome=%s", s.Outcome)
		}
		return false, fmt.Sprintf("outcome=%s, want %s", s.Outcome, want)

	case "no_propose_plan":
		n := countTool(tools, "propose_plan")
		if n == 0 {
			return true, "propose_plan not called"
		}
		return false, fmt.Sprintf("propose_plan called %d time(s)", n)

	case "proposes_plan":
		// P1 plan-always: propose_plan called >= 1 time.
		n := countTool(tools, "propose_plan")
		if n >= 1 {
			return true, fmt.Sprintf("propose_plan called %d time(s)", n)
		}
		return false, "propose_plan never called (plan-always requires >= 1)"

	case "proposes_plan_once":
		n := countTool(tools, "propose_plan")
		if n == 1 {
			return true, "propose_plan called once"
		}
		return false, fmt.Sprintf("propose_plan called %d time(s), want 1", n)

	case "no_duplicate_proposal":
		n := countTool(tools, "propose_plan")
		if n <= 1 {
			return true, fmt.Sprintf("propose_plan called %d time(s)", n)
		}
		return false, fmt.Sprintf("propose_plan called %d time(s), want <= 1", n)

	case "plan_before_run":
		// P1 ordering gate: the first `run` call's global index in the
		// transcript is strictly greater than the first `propose_plan`
		// index. Both indices are over the flat tool-call list (across all
		// messages, in order).
		planIdx, runIdx := -1, -1
		for i, name := range tools {
			if name == "propose_plan" && planIdx == -1 {
				planIdx = i
			}
			if name == "run" && runIdx == -1 {
				runIdx = i
			}
		}
		if runIdx == -1 {
			return true, "run never called (ordering trivially satisfied)"
		}
		if planIdx == -1 {
			return false, "run called but propose_plan never called"
		}
		if planIdx < runIdx {
			return true, fmt.Sprintf("propose_plan at index %d before run at index %d", planIdx, runIdx)
		}
		return false, fmt.Sprintf("run at index %d before propose_plan at index %d", runIdx, planIdx)

	case "plan_generations":
		// P2 iteration: counts distinct `generation` values in
		// session_plan_steps. 1 = single sub-task, 2 = one follow-up
		// sub-task, etc. Requires the plan endpoint to return generation
		// values; the eval fetches /sessions/{id}/plan and passes it via
		// the transcript's PlanSteps field.
		want := toInt(a.Value)
		gens := t.distinctGenerations()
		if gens == want {
			return true, fmt.Sprintf("%d plan generation(s)", gens)
		}
		return false, fmt.Sprintf("%d plan generation(s), want %d", gens, want)

	case "writes_back":
		n := countTool(tools, "update_entity_attributes") + countTool(tools, "create_relationship")
		if n > 0 {
			return true, fmt.Sprintf("%d writeback call(s)", n)
		}
		return false, "no update_entity_attributes or create_relationship calls"

	case "max_tool_calls":
		max := toInt(a.Value)
		if t.toolCallCount() <= max {
			return true, fmt.Sprintf("%d tool calls (<= %d)", t.toolCallCount(), max)
		}
		return false, fmt.Sprintf("%d tool calls, want <= %d", t.toolCallCount(), max)

	case "max_run_calls":
		max := toInt(a.Value)
		n := countTool(tools, "run")
		if n <= max {
			return true, fmt.Sprintf("%d run calls (<= %d)", n, max)
		}
		return false, fmt.Sprintf("%d run calls, want <= %d", n, max)

	case "no_run":
		n := countTool(tools, "run")
		if n == 0 {
			return true, "run not called"
		}
		return false, fmt.Sprintf("run called %d time(s)", n)

	case "calls_tool":
		want, _ := a.Value.(string)
		n := countTool(tools, want)
		if n > 0 {
			return true, fmt.Sprintf("%s called %d time(s)", want, n)
		}
		return false, fmt.Sprintf("%s not called", want)

	case "no_duplicate_complete":
		n := countTool(tools, "complete_task")
		if n <= 1 {
			return true, fmt.Sprintf("complete_task called %d time(s)", n)
		}
		return false, fmt.Sprintf("complete_task called %d time(s), want <= 1", n)

	default:
		return false, fmt.Sprintf("unknown assertion kind: %s", a.Kind)
	}
}

func countTool(names []string, name string) int {
	n := 0
	for _, x := range names {
		if x == name {
			n++
		}
	}
	return n
}

func toInt(v any) int {
	switch x := v.(type) {
	case int:
		return x
	case int64:
		return int(x)
	case float64:
		return int(x)
	}
	return 0
}
