package email

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"time"

	"github.com/kami/maven/internal/llm"
	"github.com/kami/maven/internal/persona"
)

// Extraction — turning one mail into task CANDIDATES, and nothing else.
//
// The output of this file can only ever become rows in `tasks` with status
// "candidate" (store.TaskCandidate), written through the one intake seam
// (ipc.CaptureTaskReq, Vikunja #130). That bound is the whole design:
//
//   - No reminder. A reminder FIRES; it speaks to him unprompted. A 1.7B that
//     misreads "встреча была в четверг" as a future appointment would then wake
//     him up about it. A candidate that is wrong is a line on a review page he
//     dismisses in one click, which is the correct cost of a model being wrong
//     about someone's mail.
//   - No fact. A fact is a claim Maven will later recite as true. Nothing read
//     out of a marketing mail deserves that standing.
//   - No calendar event, no note, no action. Extraction writes candidates or
//     writes nothing.
//
// The due date the model may return is stored on the candidate (tasks.due_ts),
// which no scheduler reads — it is there so the review page can sort by it.
//
// Privacy: the mail text goes to the resident model on this box and nowhere
// else. It is never search input (CLAUDE.md: "his notes and facts are never
// search input" — mail is the same class), and Evidence keeps only the subject
// line, so the review page shows him where a candidate came from without the
// store growing a copy of his mailbox.
//
// One constraint for whoever adds task context to a prompt later: a candidate's
// text is a model paraphrase of the content of his mail, and it lives in
// tasks.text. "Maven never sends his mail anywhere" holds today because nothing
// assembles a context block out of live tasks. The moment something does, mail
// content reaches whatever that block is sent to, and an outbound search would
// be sending his mailbox out a paraphrase at a time. Tasks sourced "email:" have
// to be excluded there, not here.

// MaxCandidates — at most this many candidates per message, enforced by the
// grammar. A mail with four tasks in it is a mail he has to read himself; a
// model allowed ten will produce ten.
const MaxCandidates = 3

// SourcePrefix — provenance for everything this package captures. The mailbox
// name is appended: "email:INBOX". Same vocabulary as tap:voice / poll:netdata.
const SourcePrefix = "email:"

// Candidate — one piece of work the model thinks the mail is asking for.
type Candidate struct {
	Text string `json:"text"`
	// Due — "YYYY-MM-DD" or empty. A date the model read out of the text, not a
	// date it computed: relative wording ("до пятницы") is left in Text, because
	// a small model resolving "пятница" against today's date gets it wrong often
	// enough that a stored wrong date is worse than no date.
	Due string `json:"due"`
}

// Completer — the llama-server seam, same shape memeval and the router use, so
// the one resident model serves this caller too.
type Completer interface {
	Complete(ctx context.Context, r llm.Req) (string, error)
}

// Extractor reads a message and returns candidates. It holds no store and no
// writer on purpose: this type cannot persist anything, so "extraction never
// acts" is a property of the code, not of a review.
type Extractor struct {
	llm Completer
	// MaxCandidates — 0 ⇒ MaxCandidates.
	max int
	// ContextBlock — the shared persona block, optional. Extraction output is
	// not spoken, so the persona matters less here than in the phraser; it is
	// wired anyway so a candidate reads in her voice on the review page.
	contextBlock func() string
}

func NewExtractor(c Completer, max int, contextBlock func() string) *Extractor {
	if max <= 0 || max > MaxCandidates {
		max = MaxCandidates
	}
	return &Extractor{llm: c, max: max, contextBlock: contextBlock}
}

// Max — the normalised candidate bound. Exported so the daemon logs what it will
// actually allow rather than what the config file said: 0 in the config means
// MaxCandidates here, and logging the raw value said "max 0" and then wrote
// three.
func (e *Extractor) Max() int { return e.max }

// extractGrammar — GBNF pinning the answer to a bounded array of fixed-shape
// candidates. Same reasoning as memeval's evalGrammar and the router's
// routeGrammar: the shape and the length bound are what keep a small model from
// drifting into prose or spending the token budget repeating one field.
//
// The empty array is reachable, deliberately: most mail contains no task, and a
// model with no way to say "nothing" invents something.
const extractGrammar = `
root  ::= "[" ws (item ("," ws item){0,2})? ws "]"
item  ::= "{" ws "\"text\"" ws ":" ws text "," ws "\"due\"" ws ":" ws due ws "}"
text  ::= "\"" ([^"\\] | "\\" .){1,120} "\""
due   ::= "\"\"" | "\"" [0-9]{4} "-" [0-9]{2} "-" [0-9]{2} "\""
ws    ::= [ \t\n]*
`

// extractSystem — the extraction prompt.
//
// Written around the two failure modes a small model has on this task: it
// summarises when asked to extract (turning a mail into "письмо от Антона"),
// and it invents an obligation from any polite closing sentence. Hence the
// insistence on a verb phrase, and the explicit permission to return [].
const extractSystem = `Ты читаешь одно письмо из его почты и достаёшь из него дела, которые письмо от него требует.

Правила:
- Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"text": "...", "due": "ГГГГ-ММ-ДД" или ""}.
- text — короткая формулировка дела по-русски, с глаголом: "оплатить счёт за интернет", "отправить акт". Не пересказывай письмо и не описывай его.
- Дело — это то, что должен сделать ОН. Рассылка, реклама, уведомление, отчёт, письмо «просто к сведению» — дел не содержат.
- Если письмо ничего от него не требует, верни пустой массив []. Это нормальный ответ, так бывает чаще всего.
- Ничего не придумывай. Если срока в письме нет — "".
- due заполняй только когда в письме стоит конкретная дата. Слова вроде «до пятницы» оставь в text, дату не вычисляй.
- Максимум три дела. Лучше одно точное, чем три общих.`

// Extract returns the candidates in one message.
//
// Junk is refused without an LLM call — cheapest possible defence, and the
// reason the header filter exists. An empty message (no subject, no body) is
// likewise not worth a round trip.
//
// A parse failure is an error the caller logs and moves past. It is never
// silently turned into zero candidates, because "the model went off the rails"
// and "the mail contains no task" want different reactions from a human reading
// the log.
func (e *Extractor) Extract(ctx context.Context, msg Message) ([]Candidate, error) {
	if msg.Junk {
		return nil, nil
	}
	user := renderForModel(msg)
	if user == "" {
		return nil, nil
	}
	raw, err := e.llm.Complete(ctx, llm.Req{
		System:        persona.Prepend(e.contextBlock, extractSystem),
		User:          user,
		Grammar:       extractGrammar,
		MaxTokens:     512,
		RepeatPenalty: 1.1,
	})
	if err != nil {
		return nil, fmt.Errorf("email: extract: %w", err)
	}
	items, err := parseCandidates(raw)
	if err != nil {
		// The raw reply is NOT in the error: it is a transformation of his mail,
		// and this error reaches the daemon log.
		return nil, fmt.Errorf("email: extract: unparsable reply (%d bytes)", len(raw))
	}
	out := make([]Candidate, 0, len(items))
	seen := map[string]bool{}
	for _, it := range items {
		it.Text = strings.TrimSpace(it.Text)
		if it.Text == "" {
			continue
		}
		key := strings.ToLower(strings.Join(strings.Fields(it.Text), " "))
		if seen[key] {
			continue // the model repeating itself is not two tasks
		}
		seen[key] = true
		if _, ok := ParseDue(it.Due); !ok {
			it.Due = "" // a date the grammar allowed but the calendar does not
		}
		out = append(out, it)
		if len(out) >= e.max {
			break
		}
	}
	return out, nil
}

// renderForModel is the user turn: subject, sender and body, labelled. Only
// these three fields — no headers, no recipient list, no message-id, nothing
// that would let the model start reasoning about routing metadata.
func renderForModel(msg Message) string {
	var b strings.Builder
	if msg.From != "" {
		fmt.Fprintf(&b, "От: %s\n", msg.From)
	}
	if msg.Subject != "" {
		fmt.Fprintf(&b, "Тема: %s\n", msg.Subject)
	}
	if msg.Body != "" {
		fmt.Fprintf(&b, "\n%s\n", msg.Body)
	}
	if msg.Subject == "" && msg.Body == "" {
		return ""
	}
	return b.String()
}

// parseCandidates decodes the reply and trims a fenced block or stray prose
// around the array.
//
// Through Extract that tolerance is unreachable: extractGrammar pins the first
// token to "[", so the model cannot emit reasoning before it. It is kept for
// callers that pass a raw reply from an ungrammared path, and the note is here
// so the next reader does not conclude that thinking output is expected.
func parseCandidates(raw string) ([]Candidate, error) {
	s := strings.TrimSpace(raw)
	if i := strings.Index(s, "["); i > 0 {
		s = s[i:]
	}
	if j := strings.LastIndex(s, "]"); j >= 0 {
		s = s[:j+1]
	}
	var out []Candidate
	if err := json.Unmarshal([]byte(s), &out); err != nil {
		return nil, err
	}
	return out, nil
}

// ParseDue turns the model's "YYYY-MM-DD" into a time in UTC. Exported because
// the daemon-side intake stores it on the candidate.
//
// The zero-value/empty case returns ok=false rather than an error: no date is
// the common answer, not a failure.
func ParseDue(s string) (time.Time, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return time.Time{}, false
	}
	t, err := time.Parse("2006-01-02", s)
	if err != nil {
		return time.Time{}, false
	}
	return t, true
}
