package kiwix

// This scores retrieval alone: no LLM. For each general-knowledge question we
// hand-write English keywords and ask whether the article that would answer it
// comes back in the top N hits. If this score is low, reading Wikipedia cannot
// help the model no matter how good the prompt is.
//
// The unanswerable case (know-dont-know) is not scored. Whether the junk it
// returns is "nothing useful" is a human judgement, so the report just prints
// the titles and leaves the score to the 8 answerable cases.

import (
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"strings"
)

//go:embed knowledge_v1.json
var knowledgeFixtureJSON []byte

// EvalCase — one question with hand-written keywords.
type EvalCase struct {
	ID         string   `json:"id"`
	Question   string   `json:"question"`
	Query      string   `json:"query"`
	WantTitles []string `json:"want_titles"`
	ExpectMiss bool     `json:"expect_miss"`
}

type fixture struct {
	Name  string     `json:"name"`
	Book  string     `json:"book"`
	Cases []EvalCase `json:"cases"`
}

// Outcome — what one case retrieved.
type Outcome struct {
	Case   EvalCase
	Titles []string // titles of the top N hits, in rank order
	Rank   int      // 1-based rank of the first wanted title, 0 if none
	Err    error
}

// Hit is true when a wanted title came back.
func (o Outcome) Hit() bool { return o.Rank > 0 }

// Report — the score plus per-case detail.
type Report struct {
	Name     string
	Book     string
	TopN     int
	Scored   int // answerable cases
	Hits     int
	Errors   int
	Outcomes []Outcome
}

// Accuracy over the answerable cases.
func (r Report) Accuracy() float64 {
	if r.Scored == 0 {
		return 0
	}
	return float64(r.Hits) / float64(r.Scored)
}

// RunRetrievalEval searches for every fixture case.
func RunRetrievalEval(ctx context.Context, c *Client, topN int) (Report, error) {
	var f fixture
	if err := json.Unmarshal(knowledgeFixtureJSON, &f); err != nil {
		return Report{}, err
	}
	rep := Report{Name: f.Name, Book: f.Book, TopN: topN}
	for _, cs := range f.Cases {
		res, err := c.Search(ctx, cs.Query, f.Book, topN)
		o := Outcome{Case: cs, Err: err}
		if err != nil {
			rep.Errors++
		}
		for i, hit := range res {
			o.Titles = append(o.Titles, hit.Title)
			if o.Rank == 0 && matches(cs.WantTitles, hit.Title) {
				o.Rank = i + 1
			}
		}
		if !cs.ExpectMiss {
			rep.Scored++
			if o.Hit() {
				rep.Hits++
			}
		}
		rep.Outcomes = append(rep.Outcomes, o)
	}
	return rep, nil
}

func matches(want []string, title string) bool {
	for _, w := range want {
		if strings.EqualFold(strings.TrimSpace(title), w) {
			return true
		}
	}
	return false
}

// String — the headline number.
func (r Report) String() string {
	var b strings.Builder
	fmt.Fprintf(&b, "%s: %d/%d answerable questions retrieve a wanted article in top %d (%.1f%%), %d errors\n",
		r.Name, r.Hits, r.Scored, r.TopN, 100*r.Accuracy(), r.Errors)
	fmt.Fprintf(&b, "  book: %s\n", r.Book)
	return b.String()
}

// Detail — per case: what was asked, what was searched, what came back.
func (r Report) Detail() string {
	var b strings.Builder
	for _, o := range r.Outcomes {
		mark := "MISS"
		switch {
		case o.Case.ExpectMiss:
			mark = "n/a "
		case o.Hit():
			mark = fmt.Sprintf("hit@%d", o.Rank)
		}
		fmt.Fprintf(&b, "  %-6s %-20s q=%q\n", mark, o.Case.ID, o.Case.Query)
		if o.Err != nil {
			fmt.Fprintf(&b, "         error: %v\n", o.Err)
			continue
		}
		fmt.Fprintf(&b, "         got: %s\n", strings.Join(o.Titles, " | "))
	}
	return b.String()
}
