package capture

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"unicode"
)

// DefaultChunkRunes — how much transcript goes into one summarisation prompt.
//
// The resident model runs at n_ctx 4096 and is a Thinking variant, so reasoning
// tokens need room too. Russian runs roughly 2.5–3 characters per token on a
// Qwen tokenizer, so 3000 runes is about 1100 tokens of transcript, leaving the
// prompt, the persona block, the reasoning and the answer comfortable space.
// This is the same reasoning internal/crawl used to land on 4000 runes, tightened
// because a meeting transcript is denser in named entities than a web page and
// the reduce step has to fit several summaries at once.
const DefaultChunkRunes = 3000

// DefaultMaxChunks — how many windows one meeting may be summarised in. Forty
// chunks at 3000 runes is roughly a two-hour meeting, which is MaxDuration; past
// that the transcript is truncated and the summary says so, because forty-one
// sequential model calls on this box is half an hour of work nobody is waiting
// through.
const DefaultMaxChunks = 40

// ErrNoSummary — the model returned nothing usable for every chunk.
var ErrNoSummary = errors.New("capture: model produced no summary")

// Completer is the one thing the summarizer needs from a model: text in, text
// out. It is an interface rather than an *llm.Client so this package stays pure
// and testable, and so the daemon can pass whatever it already has.
type Completer interface {
	Complete(ctx context.Context, system, user string) (string, error)
}

// Summarizer turns a transcript into something worth reading. It is map-reduce
// and nothing cleverer: summarise each window, then summarise the summaries.
//
// Truncation was the alternative and is rejected. A truncated meeting summary
// reads as complete and is not, which is worse than no summary at all — he would
// act on it.
type Summarizer struct {
	llm        Completer
	chunkRunes int
	maxChunks  int
	// context is the persona/context block the daemon prepends to every prompt,
	// or empty. Passed in rather than built here so this package does not import
	// internal/persona and the feminine self-reference rules stay in one place.
	context func() string
}

// NewSummarizer wires a summarizer. llm nil ⇒ nil Summarizer, which Recorder
// treats as "transcript only", the honest degradation with no llama-server.
// chunkRunes ≤ 0 ⇒ DefaultChunkRunes; maxChunks ≤ 0 ⇒ DefaultMaxChunks.
func NewSummarizer(llm Completer, chunkRunes, maxChunks int, contextBlock func() string) *Summarizer {
	if llm == nil {
		return nil
	}
	if chunkRunes <= 0 {
		chunkRunes = DefaultChunkRunes
	}
	if maxChunks <= 0 {
		maxChunks = DefaultMaxChunks
	}
	if contextBlock == nil {
		contextBlock = func() string { return "" }
	}
	return &Summarizer{llm: llm, chunkRunes: chunkRunes, maxChunks: maxChunks, context: contextBlock}
}

// Both prompts ask for a JSON wrapper because the daemon's Completer attaches a
// grammar of that shape (summaryGrammar in cmd/mavend/capture.go) and unwraps it
// again before the text reaches this package. The wrapper is what keeps a
// Thinking-variant model from answering a summarisation prompt with its
// reasoning. Nothing here parses it: the map and reduce steps see plain prose,
// and a Completer without the grammar still works.
//
// chunkPrompt — the map step. Deliberately plain: this is not Maven speaking to
// him, it is a model condensing text, so there is no first person in it at all
// and therefore nothing for the persona's gender rules to get wrong. The reply
// she gives him afterwards is phrased by the ordinary replier, which does carry
// the persona.
const chunkPrompt = `Ты обрабатываешь фрагмент расшифровки разговора.
Сожми его до 2-4 пунктов: о чём говорили, какие решения приняли, какие задачи назвали.
Без вступлений и выводов. Только по тексту — не придумывай того, чего в нём нет.
Если во фрагменте нет ничего содержательного, напиши одно слово: пусто.
Отвечай ТОЛЬКО объектом JSON с одним полем: {"summary": "..."}.`

// reducePrompt — the reduce step. Same rules, over the chunk summaries.
const reducePrompt = `Ниже — конспекты фрагментов одной встречи, по порядку.
Собери из них один короткий итог: о чём была встреча, какие решения приняли, что кому делать.
Не повторяйся, не придумывай, не добавляй вступлений.
Отвечай ТОЛЬКО объектом JSON с одним полем: {"summary": "..."}.`

// emptyMarker — what the map step answers for a chunk with nothing in it. Such
// chunks are dropped before the reduce step rather than padding it with noise.
const emptyMarker = "пусто"

// Summarize returns the summary and the number of chunks the transcript was
// split into. One chunk means it fit in a single prompt and the reduce step was
// skipped, which is the common case for a short meeting and saves a model call.
func (s *Summarizer) Summarize(ctx context.Context, label, transcript string) (string, int, error) {
	if s == nil {
		return "", 0, ErrDisabled
	}
	chunks := ChunkText(transcript, s.chunkRunes)
	if len(chunks) == 0 {
		return "", 0, ErrEmptyCapture
	}
	truncated := false
	if len(chunks) > s.maxChunks {
		chunks = chunks[:s.maxChunks]
		truncated = true
	}

	system := s.context() + chunkPrompt
	parts := make([]string, 0, len(chunks))
	for i, c := range chunks {
		out, err := s.llm.Complete(ctx, system, c)
		if err != nil {
			return "", len(chunks), fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
		}
		out = strings.TrimSpace(out)
		if out == "" || strings.EqualFold(out, emptyMarker) {
			continue
		}
		parts = append(parts, out)
	}
	if len(parts) == 0 {
		return "", len(chunks), ErrNoSummary
	}

	summary := parts[0]
	if len(parts) > 1 {
		joined := strings.Join(parts, "\n\n")
		reduced, err := s.llm.Complete(ctx, s.context()+reducePrompt, joined)
		if err != nil {
			// The per-chunk summaries are real work; hand them over rather than
			// losing them to a failure in the last step.
			return joined, len(chunks), fmt.Errorf("reduce: %w", err)
		}
		if r := strings.TrimSpace(reduced); r != "" {
			summary = r
		} else {
			summary = joined
		}
	}
	if label != "" {
		summary = label + "\n\n" + summary
	}
	if truncated {
		// Said in the note, not swallowed: a summary that silently covers the
		// first hour of a three-hour meeting is the failure mode this guards.
		summary += fmt.Sprintf("\n\n(расшифровка обрезана: обработано %d фрагментов из большего числа)", s.maxChunks)
	}
	return summary, len(chunks), nil
}

// ChunkText splits text into windows of at most maxRunes runes, cutting on
// sentence boundaries where it can and on a word boundary otherwise. Exported
// because it is the part worth testing on its own and the part a future
// transcript viewer will want.
//
// A sentence longer than maxRunes (a transcript with no punctuation at all,
// which whisper does produce) is cut on whitespace rather than dropped or run
// past the limit.
func ChunkText(text string, maxRunes int) []string {
	text = strings.TrimSpace(text)
	if text == "" {
		return nil
	}
	if maxRunes <= 0 {
		maxRunes = DefaultChunkRunes
	}
	if len([]rune(text)) <= maxRunes {
		return []string{text}
	}

	var out []string
	var cur []rune
	flush := func() {
		if s := strings.TrimSpace(string(cur)); s != "" {
			out = append(out, s)
		}
		cur = cur[:0]
	}
	for _, sent := range splitSentences(text) {
		sr := []rune(sent)
		if len(sr) > maxRunes {
			// Oversized sentence: emit what is buffered, then cut this one on
			// word boundaries.
			flush()
			out = append(out, splitWords(sr, maxRunes)...)
			continue
		}
		if len(cur)+len(sr) > maxRunes {
			flush()
		}
		cur = append(cur, sr...)
	}
	flush()
	return out
}

// splitSentences cuts on sentence-ending punctuation followed by a space,
// keeping the punctuation with the sentence it ends. Good enough for a
// transcript: whisper emits periods and question marks, and being wrong about an
// abbreviation costs a slightly uneven chunk, nothing more.
func splitSentences(text string) []string {
	runes := []rune(text)
	var out []string
	start := 0
	for i := 0; i < len(runes); i++ {
		if runes[i] != '.' && runes[i] != '!' && runes[i] != '?' && runes[i] != '\n' {
			continue
		}
		// Consume a run of punctuation ("?!", "...") so it stays together.
		j := i
		for j+1 < len(runes) && isSentenceEnd(runes[j+1]) {
			j++
		}
		if j+1 < len(runes) && !unicode.IsSpace(runes[j+1]) {
			i = j
			continue
		}
		end := j + 1
		for end < len(runes) && unicode.IsSpace(runes[end]) {
			end++
		}
		out = append(out, string(runes[start:end]))
		start = end
		i = end - 1
	}
	if start < len(runes) {
		out = append(out, string(runes[start:]))
	}
	return out
}

func isSentenceEnd(r rune) bool {
	return r == '.' || r == '!' || r == '?'
}

// splitWords cuts an oversized run on whitespace, falling back to a hard cut
// when a single "word" is itself longer than the limit.
func splitWords(runes []rune, maxRunes int) []string {
	var out []string
	for len(runes) > maxRunes {
		cut := maxRunes
		for cut > 0 && !unicode.IsSpace(runes[cut]) {
			cut--
		}
		if cut == 0 {
			cut = maxRunes
		}
		if s := strings.TrimSpace(string(runes[:cut])); s != "" {
			out = append(out, s)
		}
		runes = runes[cut:]
	}
	if s := strings.TrimSpace(string(runes)); s != "" {
		out = append(out, s)
	}
	return out
}
