// Package summarizer implements ports.Summarizer backed by the copied llm
// package's routing (ADR-004, extended by ADR-022). It is the only place content
// ever leaves the engine toward an AI model, so it is also the enforcement point
// for the local-first guarantee in docs/use-cases/ai_routing.feature: endpoints
// are tried in order, locals first, so content only reaches an external model
// after every local endpoint has failed — and never at all when no external
// endpoint is configured.
package summarizer

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"strings"
	"time"
	"unicode/utf8"

	"git.d-ma.be/mathias/tapir/internal/domain"
	"git.d-ma.be/mathias/tapir/internal/metrics"
)

// Completer is the minimal LLM chat surface the Summarizer needs.
// *llm.Client (and *llm.Router) satisfy it; tests use a fake.
type Completer interface {
	Complete(ctx context.Context, system, user string) (string, error)
}

// Endpoint binds a Completer to the provenance recorded on the produced Summary.
type Endpoint struct {
	Client   Completer
	Provider string // domain AIProvider: "local" | "anthropic" | "openai" | ...
	Model    string // resolved alias, e.g. "iguana/deepseek-r1-14b"
}

// Summarizer routes a transcript through an ordered chain of endpoints, trying
// each in turn until one returns a parseable summary. It owns its routing
// (rather than delegating to llm.Router) so it can record which provider answered
// and whether a fallback was used — information llm.Router collapses away. The
// chain ordering is the local-first guarantee: callers place local endpoints
// first and any external endpoint last, so content only reaches an external model
// after every local endpoint has failed.
type Summarizer struct {
	endpoints     []Endpoint
	maxInputChars int // transcript truncation budget; 0 = no limit
	now           func() time.Time
}

// New constructs a Summarizer from a primary endpoint and an optional fallback
// (the historical local-Primary -> BYO-Fallback shape, ADR-004). A nil fallback
// means a single-endpoint chain: errors are returned, content never leaves it.
func New(primary Endpoint, fallback *Endpoint) *Summarizer {
	eps := []Endpoint{primary}
	if fallback != nil {
		eps = append(eps, *fallback)
	}
	return &Summarizer{endpoints: eps, now: time.Now}
}

// NewChain constructs a Summarizer over an ordered endpoint chain (ADR-022).
// endpoints are tried in order; the first to return a parseable summary wins, and
// FallbackUsed is recorded true for any endpoint past the first. maxInputChars
// bounds the transcript text sent to every endpoint (0 = unbounded), so a long
// transcript does not overflow a small-context primary model's window. It panics
// on an empty chain — a wiring bug, not a runtime condition.
func NewChain(endpoints []Endpoint, maxInputChars int) *Summarizer {
	if len(endpoints) == 0 {
		panic("summarizer: NewChain requires at least one endpoint")
	}
	return &Summarizer{endpoints: endpoints, maxInputChars: maxInputChars, now: time.Now}
}

const systemPrompt = `You are Tapir, a video-summarization assistant.
Given a video title and transcript, produce a concise, faithful summary.
Respond with ONLY a JSON object, no prose and no code fences:
{"summary": string, "highlights": [string], "takeaways": [string]}
- "summary": 2-4 sentences capturing what the video is about.
- "highlights": the notable moments or points raised, most important first.
- "takeaways": the actionable conclusions a viewer should leave with.
Output the JSON object and nothing else.`

// Summarize implements ports.Summarizer. It walks the endpoint chain in order:
// the first endpoint whose reply parses into a non-empty summary wins. An
// endpoint is considered failed — and the next one tried — when the model call
// errors OR when its reply cannot be parsed (a 200 with malformed JSON or a
// highlights field the model emitted as a bare string). Truncation is applied
// once, up front, so every endpoint sees the same bounded prompt. When the whole
// chain fails, the joined error is returned so the engine queues the work for
// retry and delivers no summary.
func (s *Summarizer) Summarize(ctx context.Context, v domain.Video, t domain.Transcript) (domain.Summary, error) {
	if !t.HasText() {
		return domain.Summary{}, fmt.Errorf("summarize: transcript for video %s has no text", v.ID)
	}
	user := buildUserPrompt(v, t, s.maxInputChars)

	var errs []error
	for i, ep := range s.endpoints {
		fallback := i > 0
		start := time.Now()
		out, err := ep.Client.Complete(ctx, systemPrompt, user)
		dur := time.Since(start)
		if err != nil {
			metrics.ObserveSummarize(ep.Model, "error", fallback, dur)
			errs = append(errs, fmt.Errorf("%s/%s call: %w", ep.Provider, ep.Model, err))
			continue
		}
		sum, perr := s.build(v, ep, fallback, out)
		if perr != nil {
			metrics.ObserveSummarize(ep.Model, "parse_error", fallback, dur)
			errs = append(errs, fmt.Errorf("%s/%s output: %w", ep.Provider, ep.Model, perr))
			continue
		}
		metrics.ObserveSummarize(ep.Model, "success", fallback, dur)
		slog.Default().Info("summarized", "model", ep.Model, "fallback", fallback, "elapsed_ms", dur.Milliseconds())
		return sum, nil
	}
	return domain.Summary{}, fmt.Errorf("summarize: all %d endpoint(s) failed: %w", len(s.endpoints), errors.Join(errs...))
}

func (s *Summarizer) build(v domain.Video, ep Endpoint, fallbackUsed bool, raw string) (domain.Summary, error) {
	parsed, err := parse(raw)
	if err != nil {
		return domain.Summary{}, fmt.Errorf("summarize: parse %s response: %w", ep.Provider, err)
	}
	return domain.Summary{
		UserID:       v.UserID,
		VideoID:      v.ID,
		Summary:      parsed.Summary,
		Highlights:   []string(parsed.Highlights),
		Takeaways:    []string(parsed.Takeaways),
		AIProvider:   ep.Provider,
		AIModel:      ep.Model,
		FallbackUsed: fallbackUsed,
		CreatedAt:    s.now(),
	}, nil
}

func buildUserPrompt(v domain.Video, t domain.Transcript, maxInputChars int) string {
	var b strings.Builder
	fmt.Fprintf(&b, "Title: %s\n", v.Title)
	if v.URL != "" {
		fmt.Fprintf(&b, "URL: %s\n", v.URL)
	}
	fmt.Fprintf(&b, "\nTranscript:\n%s", truncate(t.Content, maxInputChars))
	return b.String()
}

// truncate caps content to max bytes on a UTF-8 rune boundary, appending a
// marker so the model knows the transcript was cut. A non-positive max (or a
// content already within budget) returns content unchanged. Bounding the input
// keeps a long transcript from overflowing a small-context model's window — the
// production failure mode where koala/phi4-mini's 8k context returned HTTP 400 on
// a 11.6k-token transcript.
func truncate(content string, max int) string {
	if max <= 0 || len(content) <= max {
		return content
	}
	cut := max
	for cut > 0 && !utf8.RuneStart(content[cut]) {
		cut--
	}
	return content[:cut] + "\n…[transcript truncated to fit the model context]"
}

// flexStrings is a []string that also unmarshals from a single JSON string or a
// JSON array of scalars. Small local models (koala/phi4-mini) sometimes emit
// "highlights": "one point" instead of an array, or mix in a number; rather than
// fail the whole summary on that quirk, coerce to []string. Empty/whitespace
// elements are dropped.
type flexStrings []string

func (f *flexStrings) UnmarshalJSON(b []byte) error {
	b = bytes.TrimSpace(b)
	if len(b) == 0 || string(b) == "null" {
		*f = nil
		return nil
	}
	if b[0] == '[' {
		var raw []json.RawMessage
		if err := json.Unmarshal(b, &raw); err != nil {
			return err
		}
		out := make([]string, 0, len(raw))
		for _, r := range raw {
			s, err := rawToString(r)
			if err != nil {
				return err
			}
			if strings.TrimSpace(s) != "" {
				out = append(out, s)
			}
		}
		*f = out
		return nil
	}
	s, err := rawToString(b)
	if err != nil {
		return err
	}
	if strings.TrimSpace(s) == "" {
		*f = nil
	} else {
		*f = flexStrings{s}
	}
	return nil
}

// rawToString renders a JSON scalar as text: a quoted string is unquoted; any
// other scalar (number, bool) is kept as its literal source so no content is lost.
func rawToString(r json.RawMessage) (string, error) {
	r = bytes.TrimSpace(r)
	if len(r) > 0 && r[0] == '"' {
		var s string
		if err := json.Unmarshal(r, &s); err != nil {
			return "", err
		}
		return s, nil
	}
	return string(r), nil
}

type parsedSummary struct {
	Summary    string      `json:"summary"`
	Highlights flexStrings `json:"highlights"`
	Takeaways  flexStrings `json:"takeaways"`
}

// parse extracts the JSON object from a model reply. Thinking models (qwen3,
// deepseek-r1) may wrap the JSON in reasoning or code fences, so we take the
// outermost {...} span rather than requiring the whole reply to be valid JSON.
func parse(raw string) (parsedSummary, error) {
	start := strings.IndexByte(raw, '{')
	end := strings.LastIndexByte(raw, '}')
	if start < 0 || end < start {
		return parsedSummary{}, fmt.Errorf("no JSON object in response")
	}
	var p parsedSummary
	if err := json.Unmarshal([]byte(raw[start:end+1]), &p); err != nil {
		return parsedSummary{}, fmt.Errorf("unmarshal: %w", err)
	}
	if strings.TrimSpace(p.Summary) == "" {
		return parsedSummary{}, fmt.Errorf("response has empty summary (thinking models need an explicit max_tokens)")
	}
	return p, nil
}
