package eval

// This file scores the CONVERSATIONAL paths, the ones the nudge fixture never
// touches: chat, query-with-notes, and general knowledge. All three now carry
// the shared persona block (internal/persona), and all three produce long
// free-form Russian — which is exactly where a persona break (formality, third
// person, masculine self-reference) is most likely and where, until this file,
// nothing could see one.
//
// Why a second fixture instead of more nudge cases: the checks differ. A nudge
// must be one short sentence with no question in it; a chat reply is allowed
// 1-3 sentences and a follow-up question is a FEATURE there. Mixing them would
// need per-case check masks, and the nudge scorer stays untouched this way.
//
// Why per-path reporting: a chat regression and a knowledge regression have
// different causes (chat prompt vs router.KnowledgePrompt), and one blended
// percentage cannot tell them apart.

import (
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"sort"
	"strings"
	"time"

	"github.com/kami/maven/internal/dialogue"
	"github.com/kami/maven/internal/router"
)

//go:embed talk_v1.json
var talkFixtureJSON []byte

// The phrasing paths under test. Values match the fixture's "path" field.
const (
	PathChat      = "chat"      // PhraseChat
	PathQuery     = "query"     // PhraseQuery with notes
	PathKnowledge = "knowledge" // PhraseQuery with no notes
	PathReply     = "reply"     // PhraseReply, the reactive confirmation
)

// TalkPaths — report order.
var TalkPaths = []string{PathChat, PathQuery, PathKnowledge, PathReply}

// TalkCheckNames — the checks that apply to a free-form reply, in report order.
// Deliberately a subset of CheckNames: length, mood and "no questions" are nudge
// properties and would fail a correct chat reply. These paths return no mood at
// all, so there is nothing to check there.
var TalkCheckNames = []string{
	CheckNonEmpty, CheckEllipsis, CheckLang, CheckFeminine, CheckAddress, CheckOnTopic,
}

// TalkCase — one turn as the daemon would present it.
//
// History is flat text because that is all PhraseChat uses (it concatenates
// turn texts into one user message); intents and slots would be dead fields.
// Notes are what the store would have matched for a query.
//
// WantAny is the on-topic contract: at least one lowercased fragment must appear
// in the reply. Fragments are stems ("пароль" → "парол") so declension does not
// defeat them.
//
// Intent, Key and Value carry the reply path's decision: that path is phrased
// from what the router already resolved, not from the raw utterance. Utterance
// stays filled anyway, because it is what a human reads in the report.
type TalkCase struct {
	ID        string   `json:"id"`
	Path      string   `json:"path"`
	Utterance string   `json:"utterance"`
	History   []string `json:"history,omitempty"`
	Notes     []string `json:"notes,omitempty"`
	Intent    string   `json:"intent,omitempty"`
	Key       string   `json:"key,omitempty"`
	Value     string   `json:"value,omitempty"`
	WantAny   []string `json:"want_any"`
	Tags      []string `json:"tags,omitempty"`
	Note      string   `json:"note,omitempty"`
}

// TalkSchemaVersion — the version this loader understands. Separate from the
// nudge fixture's SchemaVersion: the two fixtures have different shapes and
// change on different days, and one shared constant would force a bump on the
// fixture that did not move.
const TalkSchemaVersion = 1

// TalkFixture — the versioned envelope, same gating as Fixture.
type TalkFixture struct {
	SchemaVersion int        `json:"schema_version"`
	Name          string     `json:"name"`
	Notes         []string   `json:"notes"`
	Cases         []TalkCase `json:"cases"`
}

// LoadTalk returns the embedded conversational fixture.
func LoadTalk() (TalkFixture, error) {
	var f TalkFixture
	if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
		return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
	}
	if f.SchemaVersion != TalkSchemaVersion {
		return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
	}
	if len(f.Cases) == 0 {
		return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
	}
	return f, nil
}

// Talker — the methods a conversational path must have to be scorable.
// *phraser.LLMPhraser satisfies the first two; *phraser.Replier satisfies the
// third, so a run that scores all four paths passes a Pair.
type Talker interface {
	PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error)
	PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
}

// Confirmer — the reply path. *phraser.Replier satisfies it.
type Confirmer interface {
	PhraseReply(ctx context.Context, d router.Decision) (string, error)
}

// Pair joins the two objects the daemon wires separately — the phraser and the
// replier — so one ScoreTalk call covers every path Maven speaks through. A bare
// Talker still works; its reply cases score as errors, which is honest.
type Pair struct {
	Talker
	Confirmer
}

// TalkOutcome — one scored case.
type TalkOutcome struct {
	Case    TalkCase
	Reply   string
	Err     error
	Latency time.Duration
	Pass    bool
	Failed  []string
	Reasons []string
}

// TalkReport — the aggregate. ByPath is the point of this scorer.
type TalkReport struct {
	Name     string
	Total    int
	Passed   int
	Errors   int
	ByCheck  map[string]int
	ByPath   map[string]TagStat
	Outcomes []TalkOutcome
	P50      time.Duration
	P95      time.Duration
	Max      time.Duration
}

// Accuracy — fraction of cases that passed every check.
func (r TalkReport) Accuracy() float64 {
	if r.Total == 0 {
		return 0
	}
	return float64(r.Passed) / float64(r.Total)
}

// ScoreTalk runs every case through t and aggregates. A phrasing error scores as
// a miss and is counted separately: "the model was down" and "the model wrote
// something bad" must not be the same number.
func ScoreTalk(ctx context.Context, name string, t Talker, f TalkFixture) (TalkReport, error) {
	rep := TalkReport{
		Name:    name,
		Total:   len(f.Cases),
		ByCheck: map[string]int{},
		ByPath:  map[string]TagStat{},
	}
	for _, n := range TalkCheckNames {
		rep.ByCheck[n] = 0
	}
	lat := make([]time.Duration, 0, len(f.Cases))

	for _, c := range f.Cases {
		start := time.Now()
		reply, err := c.run(ctx, t)
		o := TalkOutcome{Case: c, Reply: reply, Err: err, Latency: time.Since(start)}
		lat = append(lat, o.Latency)

		if err != nil {
			rep.Errors++
			o.Failed = append(o.Failed, "call")
			o.Reasons = append(o.Reasons, fmt.Sprintf("phrase error: %v", err))
		} else {
			for _, res := range RunTalkChecks(c, reply) {
				if res.Pass {
					rep.ByCheck[res.Name]++
					continue
				}
				o.Failed = append(o.Failed, res.Name)
				o.Reasons = append(o.Reasons, res.Name+": "+res.Detail)
			}
		}

		o.Pass = len(o.Failed) == 0
		if o.Pass {
			rep.Passed++
		}
		bump(rep.ByPath, c.Path, o.Pass)
		rep.Outcomes = append(rep.Outcomes, o)
	}

	sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
	rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95)
	if len(lat) > 0 {
		rep.Max = lat[len(lat)-1]
	}
	return rep, nil
}

// run dispatches the case to its path. knowledge and query are the same method;
// the empty notes slice is what selects the no-notes branch inside PhraseQuery.
func (c TalkCase) run(ctx context.Context, t Talker) (string, error) {
	switch c.Path {
	case PathChat:
		return t.PhraseChat(ctx, c.Utterance, c.turns())
	case PathQuery:
		return t.PhraseQuery(ctx, c.Utterance, c.Notes)
	case PathKnowledge:
		return t.PhraseQuery(ctx, c.Utterance, nil)
	case PathReply:
		conf, ok := t.(Confirmer)
		if !ok {
			return "", fmt.Errorf("target cannot phrase replies — pass a Pair")
		}
		return conf.PhraseReply(ctx, c.decision())
	}
	return "", fmt.Errorf("unknown path %q", c.Path)
}

// decision rebuilds what the router would have handed the replier. Text is the
// utterance for a note or a reminder, which is what the router puts there.
func (c TalkCase) decision() router.Decision {
	return router.Decision{
		Intent: router.Intent(c.Intent),
		Slots: router.Slots{
			Key:    c.Key,
			Value:  c.Value,
			Text:   c.Utterance,
			HasKey: c.Key != "",
		},
	}
}

func (c TalkCase) turns() []dialogue.Turn {
	turns := make([]dialogue.Turn, 0, len(c.History))
	for _, h := range c.History {
		turns = append(turns, dialogue.Turn{Text: h})
	}
	return turns
}

// RunTalkChecks scores one reply. Order matches TalkCheckNames.
func RunTalkChecks(c TalkCase, reply string) []Result {
	return []Result{
		checkNonEmpty(reply),
		checkEllipsis(reply),
		checkLang(reply),
		checkFeminine(reply),
		checkAddress(reply),
		checkOnTopicAny(c.WantAny, reply),
	}
}

// String renders the comparison table — composite, then per-check so a
// regression names the property, then per-path so it names the prompt.
func (r TalkReport) String() string {
	var b strings.Builder
	fmt.Fprintf(&b, "%s: %d/%d cases pass every check (%.1f%%), %d errors\n",
		r.Name, r.Passed, r.Total, 100*r.Accuracy(), r.Errors)
	for _, name := range TalkCheckNames {
		fmt.Fprintf(&b, "  %-10s %d/%d\n", name, r.ByCheck[name], r.Total)
	}
	fmt.Fprintf(&b, "  latency: p50 %s  p95 %s  max %s\n", r.P50, r.P95, r.Max)
	fmt.Fprintf(&b, "  by path: %s\n", renderStats(r.ByPath))
	return b.String()
}

// Failures — per-case detail, sorted by ID so two runs diff cleanly.
func (r TalkReport) Failures() string {
	var b strings.Builder
	for _, o := range r.sorted() {
		if o.Pass {
			continue
		}
		fmt.Fprintf(&b, "  %s %q\n    %s\n", o.Case.ID, o.Reply, strings.Join(o.Reasons, "; "))
	}
	return b.String()
}

// Replies — every generated reply verbatim. This is what a human reads to judge
// tone; the score only says which checks fired.
func (r TalkReport) Replies() string {
	var b strings.Builder
	for _, o := range r.sorted() {
		mark := "ok  "
		if !o.Pass {
			mark = "FAIL"
		}
		fmt.Fprintf(&b, "  %s %-9s %-22s %q\n", mark, o.Case.Path, o.Case.ID, o.Reply)
	}
	return b.String()
}

func (r TalkReport) sorted() []TalkOutcome {
	out := append([]TalkOutcome(nil), r.Outcomes...)
	sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
	return out
}
