// Package usecase holds Tapir's application core: the engine that detects new
// videos, resolves transcripts, summarizes, and delivers to sinks. It depends
// only on internal/ports and internal/domain.
//
// This is a SCAFFOLD. Methods return ErrNotImplemented so the acceptance tests
// in test/acceptance fail RED. Implementing them to make those tests pass is
// the first build task (see the build issue). Behaviour is specified in
// docs/use-cases/*.feature.
package usecase

import (
	"context"
	"errors"
	"fmt"
	"sync"

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

// ErrNotImplemented marks scaffold methods awaiting implementation.
var ErrNotImplemented = errors.New("not implemented")

// Engine is the provider- and sink-agnostic core.
type Engine struct {
	Source ports.VideoSource
	AI     ports.Summarizer
	Sinks  []ports.Sink

	// Transcripts, when set, is the shared transcript cache (ADR-021): the engine
	// reads it before any caption fetch and writes resolved transcripts back, so
	// re-analysis — the same user re-summarizing, or a second user with the same
	// video — never re-touches YouTube (ADR-010/014). Optional: nil disables
	// persistence (fetch every time), keeping the pure-core/scaffold wiring valid.
	Transcripts ports.TranscriptStore

	// processed dedups videos within this engine's lifetime so a video is not
	// summarized twice when the watcher sees it again. Durable cross-restart
	// dedup is the store's concern (a resolved TRANSCRIPT / existing SUMMARY,
	// see docs/data-model.md), not the engine core's — no port is invented here.
	mu        sync.Mutex
	processed map[string]bool
}

// NewEngine wires the engine from its ports.
func NewEngine(src ports.VideoSource, ai ports.Summarizer, sinks ...ports.Sink) *Engine {
	return &Engine{Source: src, AI: ai, Sinks: sinks, processed: make(map[string]bool)}
}

// ProcessResult reports what happened for one video.
type ProcessResult struct {
	Video   domain.Video
	Skipped bool
	Reason  string // set when Skipped (e.g. "no transcript")
	// TranscriptSource is how the transcript resolved (or that there was none):
	// the domain.TranscriptSource value as a string. The runner reads it to tell a
	// permanent absence (SourceNone) from a transient 429 (SourceRateLimited) and
	// persist the right transcript_status. Empty when a fetch error short-circuits.
	TranscriptSource string
	Summary          *domain.Summary // nil when Skipped
}

// ProcessNewVideo runs the core use case for a single video:
// resolve transcript -> (summarize -> deliver) | skip.
// See docs/use-cases/summarize_new_video.feature.
func (e *Engine) ProcessNewVideo(ctx context.Context, v domain.Video) (ProcessResult, error) {
	t, err := e.resolveTranscript(ctx, v)
	if err != nil {
		return ProcessResult{Video: v}, err
	}
	if !t.HasText() {
		// No usable transcript: record the skip, produce no summary, deliver nothing
		// (captions-first, ADR-007; the watcher uses this to avoid reprocessing).
		// Surface the source so the runner separates SourceNone (permanent) from
		// SourceRateLimited (retry after a backoff window).
		return ProcessResult{Video: v, Skipped: true, Reason: "no transcript", TranscriptSource: string(t.Source)}, nil
	}

	sum, err := e.AI.Summarize(ctx, v, t)
	if err != nil {
		return ProcessResult{Video: v}, fmt.Errorf("summarize: %w", err)
	}

	// Sinks fail independently: a failing sink must not abort the others, and
	// successful deliveries are not dropped. Collect every error, return them joined.
	var errs []error
	for _, sink := range e.Sinks {
		if err := sink.Deliver(ctx, sum); err != nil {
			errs = append(errs, fmt.Errorf("deliver to %s: %w", sink.Name(), err))
		}
	}

	return ProcessResult{Video: v, Summary: &sum, TranscriptSource: string(t.Source)}, errors.Join(errs...)
}

// resolveTranscript returns v's transcript, reading the shared store first
// (ADR-021): a stored transcript — including a stored SourceNone (captions
// permanently absent) — is returned without touching YouTube, so re-analysis
// never re-fetches. On a store miss it fetches through the source (which gates
// the caption call, ADR-014) and persists the terminal outcome so the next
// analysis, for any user, reads from the store. A transient SourceRateLimited is
// returned to the caller (the runner stamps a per-user backoff) but never stored,
// so persistence can never mask a 429 as a permanent "no transcript". When no
// TranscriptStore is wired the engine simply fetches every time.
func (e *Engine) resolveTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) {
	if e.Transcripts != nil {
		stored, ok, err := e.Transcripts.GetTranscript(ctx, string(v.Provider), v.ProviderVideoID)
		if err != nil {
			return domain.Transcript{}, fmt.Errorf("get stored transcript: %w", err)
		}
		if ok {
			return stored, nil
		}
	}

	t, err := e.Source.FetchTranscript(ctx, v)
	if err != nil {
		return domain.Transcript{}, fmt.Errorf("fetch transcript: %w", err)
	}

	if e.Transcripts != nil && t.Source != domain.SourceRateLimited {
		if err := e.Transcripts.SaveTranscript(ctx, string(v.Provider), v.ProviderVideoID, t); err != nil {
			return domain.Transcript{}, fmt.Errorf("save transcript: %w", err)
		}
	}
	return t, nil
}

// ProcessNewVideos walks a user's subscriptions and processes each newly seen
// video. Only videos surfaced via the user's subscriptions are considered, so a
// channel the user is not subscribed to is never processed. A video already
// processed in this engine's lifetime is skipped, so it is not summarized twice.
// Per-video errors are collected (one failing video does not abort the rest) and
// returned joined alongside the results gathered.
func (e *Engine) ProcessNewVideos(ctx context.Context, userID string) ([]ProcessResult, error) {
	subs, err := e.Source.ListSubscriptions(ctx, userID)
	if err != nil {
		return nil, fmt.Errorf("list subscriptions: %w", err)
	}

	var (
		results []ProcessResult
		errs    []error
	)
	for _, sub := range subs {
		vids, err := e.Source.NewVideos(ctx, sub)
		if err != nil {
			errs = append(errs, fmt.Errorf("new videos for %q: %w", sub.ChannelTitle, err))
			continue
		}
		for _, v := range vids {
			if e.alreadyProcessed(v) {
				continue
			}
			res, err := e.ProcessNewVideo(ctx, v)
			if err != nil {
				errs = append(errs, err)
			}
			// Mark processed once the transcript was resolved (summarized or
			// skipped), so the watcher does not re-resolve or re-summarize it.
			// A fetch/summarize failure leaves it unmarked, allowing a retry.
			if res.Summary != nil || res.Skipped {
				e.markProcessed(v)
			}
			results = append(results, res)
		}
	}
	return results, errors.Join(errs...)
}

func (e *Engine) alreadyProcessed(v domain.Video) bool {
	e.mu.Lock()
	defer e.mu.Unlock()
	return e.processed[processedKey(v)]
}

func (e *Engine) markProcessed(v domain.Video) {
	e.mu.Lock()
	defer e.mu.Unlock()
	if e.processed == nil {
		e.processed = make(map[string]bool)
	}
	e.processed[processedKey(v)] = true
}

// processedKey is per-user (per-user isolation, docs/data-model.md): the same
// video seen by two users is two distinct rows and must be keyed separately.
func processedKey(v domain.Video) string {
	return v.UserID + "\x00" + v.ID
}
