// Package summarizer implements ports.Summarizer backed by the copied llm
// package's local-Primary -> BYO-Fallback routing (ADR-004). 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: a user with no BYO provider configured has
// their content sent to the local stack and nowhere else.
package summarizer

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

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

// 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 the local endpoint first, then the
// optional BYO endpoint. It owns its routing (rather than delegating to
// llm.Router) so it can record which provider answered and whether the fallback
// was used — information llm.Router collapses away.
type Summarizer struct {
	primary  Endpoint
	fallback *Endpoint // nil => no BYO; primary errors are returned, never sent externally
	now      func() time.Time
}

// New constructs a Summarizer. fallback may be nil (no BYO provider configured).
func New(primary Endpoint, fallback *Endpoint) *Summarizer {
	return &Summarizer{primary: primary, fallback: fallback, 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.
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)

	// Primary = local stack. Only on its failure is anything sent externally,
	// and only when a BYO fallback is configured.
	out, err := s.primary.Client.Complete(ctx, systemPrompt, user)
	if err == nil {
		return s.build(v, s.primary, false, out)
	}

	if s.fallback == nil {
		// No BYO: content was sent to the local stack only. Surface the error so
		// the engine can queue the work for retry; deliver no summary.
		return domain.Summary{}, fmt.Errorf("summarize: local AI failed and no BYO provider configured: %w", err)
	}

	out, ferr := s.fallback.Client.Complete(ctx, systemPrompt, user)
	if ferr != nil {
		return domain.Summary{}, fmt.Errorf("summarize: local AI failed: %w; BYO %s failed: %v", err, s.fallback.Provider, ferr)
	}
	return s.build(v, *s.fallback, true, out)
}

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:   parsed.Highlights,
		Takeaways:    parsed.Takeaways,
		AIProvider:   ep.Provider,
		AIModel:      ep.Model,
		FallbackUsed: fallbackUsed,
		CreatedAt:    s.now(),
	}, nil
}

func buildUserPrompt(v domain.Video, t domain.Transcript) 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", t.Content)
	return b.String()
}

type parsedSummary struct {
	Summary    string   `json:"summary"`
	Highlights []string `json:"highlights"`
	Takeaways  []string `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
}
