package phraser

// The persona guard for the Go floor strings.
//
// internal/phraser/eval/fallbacks_test.go already scores everything Variants()
// returns — that is the JSON decks. What it cannot see is the floor UNDER those
// decks: the hardFloor/ackFloor/queryFloor/actFloor/confirmFloor maps and the
// literals in nudge_llm.go, which are what she says when the JSON is unusable or
// when the model is unreachable. Those are exactly the moments the model is not
// doing the talking, so leaving them unscored left the persona unchecked when it
// was most load-bearing (Vikunja #621).
//
// Two tests here, and the second one is the point:
//
//   - TestGoFloorPersona scores the floor corpus on the same checks.
//   - TestGoFloorCoverage walks the package source with go/ast and fails on any
//     Russian string literal that neither reached the corpus nor sits inside a
//     declaration declared prompt-side. A hand-written list of strings would rot
//     the first time somebody adds one; a hand-written list of PROMPT BUILDERS
//     does not, because the default for a new literal is "must be scored".

import (
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"io/fs"
	"regexp"
	"strconv"
	"strings"
	"testing"
	"time"
	"unicode"

	"github.com/kami/maven/internal/loop"
	"github.com/kami/maven/internal/phraser/eval"
	"github.com/kami/maven/internal/store"
)

// personaChecks — the checks that apply to a floor line.
//
// The same three the non-goals section names (feminine self-reference, how she
// addresses him, no pet names), plus lang: an English floor line is unusable out
// loud. No hisgender, for the reason eval/fallbacks_test.go gives — her own
// feminine verb near "тебе" is correct and that check reads it as addressing him
// as a woman. No length, because a floor line composed from his own data has no
// bounded length, and no ontopic/mood, which need a fixture case.
var personaChecks = map[string]bool{
	eval.CheckLang:     true,
	eval.CheckFeminine: true,
	eval.CheckAddress:  true,
	eval.CheckCringe:   true,
}

var floorPlaceholderRE = regexp.MustCompile(`\{[a-z_]+\}`)

// formatVerbRE — the fmt verbs a floor line is composed with, so the coverage
// test compares the Russian either side of them and not the verb.
var formatVerbRE = regexp.MustCompile(`%[+\-# 0-9.]*[a-zA-Z]`)

// floorLine — one scored string and where it came from, so a failure names the
// map or the function to go and edit.
type floorLine struct {
	origin string
	text   string
}

// floorCorpus — every line the Go floor can produce. Maps are read whole, so a
// new entry in one is scored without touching this file; the composing functions
// are CALLED rather than scraped, so their glue text is scored in place.
func floorCorpus() []floorLine {
	var out []floorLine
	add := func(origin, text string) {
		if strings.TrimSpace(text) != "" {
			out = append(out, floorLine{origin, text})
		}
	}
	for name, m := range map[string]map[string]string{
		"fallbacks.go hardFloor":      hardFloor,
		"acks.go ackFloor":            ackFloor,
		"query.go queryFloor":         queryFloor,
		"acts.go actFloor":            actFloor,
		"confirm.go confirmFloor":     confirmFloor,
		"nudge_llm.go fallbackNudges": fallbackNudges,
	} {
		for key, text := range m {
			add(name+"["+key+"]", text)
		}
	}

	// fallbackNudge composes three of its four arms in Go. Drive every rule name
	// the maps know, one it does not, and the down-services arm.
	rules := map[string]bool{"": true, "unknown_rule": true}
	for name := range fallbackNudges {
		rules[name] = true
	}
	for name := range ruleTopics {
		rules[name] = true
	}
	for name := range ruleKeywords {
		rules[name] = true
	}
	for name := range rules {
		c := loop.Candidate{}
		c.Rule.Name = name
		add(fmt.Sprintf("nudge_llm.go fallbackNudge(%q)", name), fallbackNudge(c))
	}
	// The keyword arm again, through a rule name shaped "family:keyword", which
	// is where ruleKeyword's second branch lives.
	c := loop.Candidate{}
	c.Rule.Name = "custom:зарядку"
	add("nudge_llm.go fallbackNudge(custom)", fallbackNudge(c))

	// The keywords themselves. A rule that has both a keyword and a fallback
	// line never reaches the keyword arm, but the map is edited as one thing and
	// the next rule may have only the keyword, so score every value.
	for rule, kw := range ruleKeywords {
		add("nudge_llm.go ruleKeywords["+rule+"]", "Напоминаю: "+kw+".")
	}

	// The down-services arm, which needs a service actually reading down.
	down := loop.Candidate{}
	down.Rule.Name = "service_down"
	down.State.Facts = map[string]store.Fact{
		loop.ServiceDownPrefix + "gitea": {
			Key:    loop.ServiceDownPrefix + "gitea",
			Value:  `"down"`,
			Source: loop.ServiceDownSource,
			Ts:     time.Now(),
		},
	}
	add("nudge_llm.go fallbackNudge(down services)", fallbackNudge(down))

	// The spoken duration words. Both functions are pure and bounded, so scoring
	// their whole range beats scraping the literals out of the switch.
	for m := 0; m <= 60*30; m += 7 {
		d := time.Duration(m) * time.Minute
		add("nudge_llm.go ruDur", ruDur(d))
		add("nudge_templates.go ruSinceWords", ruSinceWords(d))
	}
	for h := 0; h <= hoursSpoken; h++ {
		add("nudge_templates.go hourPlural", hourPlural(h))
		add("nudge_templates.go hourWord", hourWord(h))
	}
	return out
}

// TestGoFloorPersona scores every line the Go floor can say.
func TestGoFloorPersona(t *testing.T) {
	corpus := floorCorpus()
	if len(corpus) == 0 {
		t.Fatal("no floor lines — the corpus builder found nothing to score")
	}
	for _, line := range corpus {
		// A placeholder stands for his own words and carries no persona.
		body := floorPlaceholderRE.ReplaceAllString(line.text, "вода")
		for _, r := range eval.RunChecks(eval.Case{}, body, "neutral") {
			if personaChecks[r.Name] && !r.Pass {
				t.Errorf("%s: %q fails %s: %s", line.origin, line.text, r.Name, r.Detail)
			}
		}
	}
}

// promptDecls — declarations whose Russian is written FOR the model, not for
// him. They are excluded by name, not by string, so adding a line inside one of
// them stays excluded and adding a line anywhere else fails the coverage test.
//
// Every name here is asserted to still exist, so a rename fails loudly instead
// of silently widening the exemption.
var promptDecls = map[string]string{
	"ReplySystemPrompt": "the system prompt for the reply model",
	"replyContext":      "renders the decision FOR the model, never spoken",
	"ruleTopics":        "situation descriptions fed to the nudge prompt",
	"ruleTopic":         "same, plus the two prefixes it composes",
	"buildNudgePrompt":  "the nudge prompt itself",
	"chatUserMessage":   "the history block handed to the model",
	"PhraseReminder":    "the reminder prompt; its reply is scored, its prompt is not",
}

// promptFiles — files whose whole job is prompt text. Asserted to exist, same
// reason as promptDecls.
var promptFiles = map[string]string{
	"prompts.go": "every literal in it is a prompt",
}

func hasCyrillic(s string) bool {
	for _, r := range s {
		if unicode.Is(unicode.Cyrillic, r) {
			return true
		}
	}
	return false
}

// TestGoFloorCoverage — the guard that survives the next person.
//
// It reads the package source and requires every Russian string literal to be
// one of two things: reachable in floorCorpus (so TestGoFloorPersona scored it),
// or inside a declaration named above as prompt-side. There is no third answer
// and no way to add a floor string that quietly gets neither.
func TestGoFloorCoverage(t *testing.T) {
	var scored []string
	for _, line := range floorCorpus() {
		scored = append(scored, line.text)
	}
	// A literal is covered when every Russian piece of it shows up in something
	// the corpus scored. Pieces, not the whole string, because a format string
	// ("%d ч") and a concatenation fragment ("Не отвечает: ") only ever reach him
	// with the surrounding value filled in.
	covered := func(lit string) bool {
		for _, part := range formatVerbRE.Split(lit, -1) {
			part = strings.TrimSpace(part)
			if part == "" || !hasCyrillic(part) {
				continue
			}
			found := false
			for _, s := range scored {
				if strings.Contains(s, part) {
					found = true
					break
				}
			}
			if !found {
				return false
			}
		}
		return true
	}

	fset := token.NewFileSet()
	pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool {
		return !strings.HasSuffix(fi.Name(), "_test.go")
	}, 0)
	if err != nil {
		t.Fatalf("parse package: %v", err)
	}
	pkg, ok := pkgs["phraser"]
	if !ok {
		t.Fatal("package phraser did not parse — the coverage guard cannot run")
	}

	seenDecl := map[string]bool{}
	seenFile := map[string]bool{}
	for path, file := range pkg.Files {
		base := path[strings.LastIndexByte(path, '/')+1:]
		if _, exempt := promptFiles[base]; exempt {
			seenFile[base] = true
			continue
		}
		for _, decl := range file.Decls {
			names := declNames(decl)
			skip := false
			for _, n := range names {
				if _, ok := promptDecls[n]; ok {
					seenDecl[n] = true
					skip = true
				}
			}
			if skip {
				continue
			}
			ast.Inspect(decl, func(n ast.Node) bool {
				bl, ok := n.(*ast.BasicLit)
				if !ok || bl.Kind != token.STRING {
					return true
				}
				lit, err := strconv.Unquote(bl.Value)
				if err != nil || !hasCyrillic(lit) {
					return true
				}
				if !covered(lit) {
					t.Errorf("%s: Russian literal %q is spoken by nothing the persona guard scores.\n"+
						"Either reach it from floorCorpus in persona_floor_test.go, or — if it is written "+
						"for the model rather than for him — name its declaration in promptDecls.",
						fset.Position(bl.Pos()), lit)
				}
				return true
			})
		}
	}
	for name, why := range promptDecls {
		if !seenDecl[name] {
			t.Errorf("promptDecls names %q (%s) and no such declaration exists — "+
				"a rename left the exemption open", name, why)
		}
	}
	for base, why := range promptFiles {
		if !seenFile[base] {
			t.Errorf("promptFiles names %q (%s) and no such file exists", base, why)
		}
	}
}

// declNames — the names a top-level declaration binds, so a prompt-side var,
// const or func can be matched whatever kind it is.
func declNames(decl ast.Decl) []string {
	switch d := decl.(type) {
	case *ast.FuncDecl:
		return []string{d.Name.Name}
	case *ast.GenDecl:
		var out []string
		for _, spec := range d.Specs {
			switch s := spec.(type) {
			case *ast.ValueSpec:
				for _, n := range s.Names {
					out = append(out, n.Name)
				}
			case *ast.TypeSpec:
				out = append(out, s.Name.Name)
			}
		}
		return out
	}
	return nil
}
