package youtube

import (
	"bytes"
	"context"
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"strings"
	"time"

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

// defaultPlayerBaseURL is the InnerTube / watch-page host. Overridable via
// Config.PlayerBaseURL (tests point it at an httptest server).
const defaultPlayerBaseURL = "https://www.youtube.com"

// androidUserAgent identifies the InnerTube ANDROID client. ANDROID historically
// returns caption baseUrls without a PoToken requirement (ADR-010).
const androidUserAgent = "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip"

// browserUserAgent is required for the watch-page scrape: with an app UA YouTube
// serves a page WITHOUT the embedded ytInitialPlayerResponse, so captionTracks
// come back empty. A desktop-browser UA returns the player JSON with captions.
const browserUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"

// maxCaptionBytes bounds a single timedtext/player body read.
const maxCaptionBytes = 16 << 20 // 16 MiB

// FetchTranscript resolves a transcript captions-first (ADR-007) via the player
// response + timedtext baseUrl, unauthenticated (ADR-010 — Data API
// captions.download is owner-only and 403s for videos the user does not own):
//
//   - POST the InnerTube player endpoint (ANDROID client) and read
//     captions.playerCaptionsTracklistRenderer.captionTracks[]. None => SourceNone.
//   - Select a track (preferred language, non-asr first) and GET its baseUrl with
//     a plain http.Client (no OAuth token — it can break the timedtext endpoint),
//     parsing srv3 XML / json3 / legacy XML to plain text.
//
// Degrade, don't error (ADR-010): no captionTracks, empty baseUrl, a non-200
// fetch, or an unparseable body all yield SourceNone rather than an error. Only
// genuine transport (network) faults return an error. Audio download and
// speech-to-text remain absent (ADR-007).
// FetchTranscript times the caption fetch and records its latency by outcome
// (ADR-030) before returning. Transport errors are surfaced to the caller and not
// recorded as an outcome (logged upstream); the three resolved outcomes
// captions|none|rate_limited are the ones that consume the scarce fetch budget.
func (a *Adapter) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) {
	start := time.Now()
	tr, err := a.fetchTranscript(ctx, v)
	if err == nil {
		dur := time.Since(start)
		outcome := captionOutcome(tr.Source)
		metrics.ObserveCaptionFetch(outcome, dur)
		slog.Default().Info("caption fetch", "video", v.ProviderVideoID, "outcome", outcome, "elapsed_ms", dur.Milliseconds())
	}
	return tr, err
}

// captionOutcome maps a transcript source to the metric outcome label.
func captionOutcome(s domain.TranscriptSource) string {
	switch s {
	case domain.SourceCaptions:
		return "captions"
	case domain.SourceRateLimited:
		return "rate_limited"
	default:
		return "none"
	}
}

func (a *Adapter) fetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) {
	client := a.plainClient()

	tracks, err := a.captionTracks(ctx, client, v.ProviderVideoID)
	if err != nil {
		return domain.Transcript{}, fmt.Errorf("resolve caption tracks for %q: %w", v.ProviderVideoID, err)
	}

	track, ok := a.selectTrack(tracks)
	if !ok || strings.TrimSpace(track.BaseURL) == "" {
		return noTranscript(v), nil
	}

	raw, status, err := a.httpGet(ctx, client, track.BaseURL, nil)
	if err != nil {
		return domain.Transcript{}, fmt.Errorf("download caption track for %q: %w", v.ProviderVideoID, err)
	}
	if status == http.StatusTooManyRequests {
		// 429 means the IP is rate-limited; record for retry, not a permanent
		// absence. Degrade gracefully (no error, no text) like SourceNone, but
		// flag it distinctly so the runner backs off and retries (ADR-007/010).
		return domain.Transcript{VideoID: v.ID, UserID: v.UserID, Source: domain.SourceRateLimited}, nil
	}
	if status != http.StatusOK {
		// Owner-only 403, region/age gate, or transient unavailability: not an error.
		return noTranscript(v), nil
	}

	text := timedtextToText(string(raw))
	if text == "" {
		return noTranscript(v), nil
	}

	return domain.Transcript{
		VideoID:  v.ID,
		UserID:   v.UserID,
		Source:   domain.SourceCaptions,
		Language: track.LanguageCode,
		Content:  text,
	}, nil
}

// noTranscript is the recorded "checked, none usable" result — not an error.
func noTranscript(v domain.Video) domain.Transcript {
	return domain.Transcript{VideoID: v.ID, UserID: v.UserID, Source: domain.SourceNone}
}

// captionTracks resolves a video's caption tracks from the player response. It
// tries the InnerTube ANDROID client first and falls back to scraping
// ytInitialPlayerResponse from the watch page. A nil slice (no tracks) is a
// degrade, not an error; only transport faults return an error.
func (a *Adapter) captionTracks(ctx context.Context, client *http.Client, videoID string) ([]captionTrack, error) {
	tracks, err := a.playerCaptionTracks(ctx, client, videoID)
	if err != nil {
		return nil, err
	}
	if len(tracks) > 0 {
		return tracks, nil
	}
	// Fallback: scrape the watch page (no PoToken/InnerTube context needed).
	return a.scrapeCaptionTracks(ctx, client, videoID)
}

// playerCaptionTracks POSTs the InnerTube player endpoint with the ANDROID client
// context (unauthenticated) and returns its caption tracks.
func (a *Adapter) playerCaptionTracks(ctx context.Context, client *http.Client, videoID string) ([]captionTrack, error) {
	reqBody, err := json.Marshal(playerRequest{
		Context: innertubeContext{Client: innertubeClient{
			ClientName:        "ANDROID",
			ClientVersion:     "20.10.38",
			AndroidSDKVersion: 30,
			HL:                "en",
			GL:                "US",
		}},
		VideoID: videoID,
	})
	if err != nil {
		return nil, fmt.Errorf("encode player request: %w", err)
	}

	body, status, err := a.httpPost(ctx, client, a.playerBaseURL+"/youtubei/v1/player", reqBody)
	if err != nil {
		return nil, err
	}
	if status != http.StatusOK {
		return nil, nil // degrade
	}

	var resp playerResponse
	if err := json.Unmarshal(body, &resp); err != nil {
		return nil, nil // unparseable => degrade
	}
	return resp.Captions.PlayerCaptionsTracklistRenderer.CaptionTracks, nil
}

// ytInitialMarker locates the embedded player JSON on the watch page.
const ytInitialMarker = "ytInitialPlayerResponse"

// scrapeCaptionTracks fetches the watch page and extracts caption tracks from the
// embedded ytInitialPlayerResponse JSON. Any failure degrades to no tracks.
func (a *Adapter) scrapeCaptionTracks(ctx context.Context, client *http.Client, videoID string) ([]captionTrack, error) {
	body, status, err := a.httpGet(ctx, client, a.playerBaseURL+"/watch?v="+videoID, map[string]string{
		"User-Agent": browserUserAgent,
	})
	if err != nil {
		return nil, err
	}
	if status != http.StatusOK {
		return nil, nil
	}
	obj, ok := extractJSONObject(body, ytInitialMarker)
	if !ok {
		return nil, nil
	}
	var resp playerResponse
	if err := json.Unmarshal(obj, &resp); err != nil {
		return nil, nil
	}
	return resp.Captions.PlayerCaptionsTracklistRenderer.CaptionTracks, nil
}

// selectTrack picks the best caption track: a preferred-language non-asr track
// first, then a preferred-language asr track, then any non-asr track, else the
// first track. Returns ok == false when there are no tracks at all.
func (a *Adapter) selectTrack(tracks []captionTrack) (captionTrack, bool) {
	if len(tracks) == 0 {
		return captionTrack{}, false
	}
	for _, asr := range []bool{false, true} {
		for _, pref := range a.cfg.PreferredLanguages {
			for _, t := range tracks {
				if t.isASR() == asr && matchLang(t.LanguageCode, pref) {
					return t, true
				}
			}
		}
	}
	for _, t := range tracks {
		if !t.isASR() {
			return t, true
		}
	}
	return tracks[0], true
}

// matchLang matches a track language against a preferred code, tolerating region
// suffixes (preferred "en" matches "en", "en-US", "en-GB").
func matchLang(code, pref string) bool {
	if strings.EqualFold(code, pref) {
		return true
	}
	return strings.HasPrefix(strings.ToLower(code), strings.ToLower(pref)+"-")
}

// --- HTTP (plain, unauthenticated) ------------------------------------------

// plainClient returns an unauthenticated HTTP client. No OAuth token is attached:
// the player/timedtext endpoints can reject authenticated requests (ADR-010).
// Tests inject the httptest transport via a.transport.
func (a *Adapter) plainClient() *http.Client {
	if a.transport != nil {
		return &http.Client{Transport: a.transport}
	}
	return &http.Client{}
}

func (a *Adapter) httpGet(ctx context.Context, client *http.Client, url string, headers map[string]string) ([]byte, int, error) {
	return a.httpDo(ctx, client, http.MethodGet, url, nil, headers)
}

func (a *Adapter) httpPost(ctx context.Context, client *http.Client, url string, body []byte) ([]byte, int, error) {
	return a.httpDo(ctx, client, http.MethodPost, url, body, map[string]string{
		"Content-Type": "application/json",
		"User-Agent":   androidUserAgent,
	})
}

// httpDo issues a request and returns (body, status, err). A non-nil err is a
// genuine transport fault; a non-200 status is returned to the caller to decide
// (callers degrade rather than error per ADR-010).
func (a *Adapter) httpDo(ctx context.Context, client *http.Client, method, url string, body []byte, headers map[string]string) ([]byte, int, error) {
	var rdr io.Reader
	if body != nil {
		rdr = bytes.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, method, url, rdr)
	if err != nil {
		return nil, 0, fmt.Errorf("build %s %s: %w", method, url, err)
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	// Process-wide rate gate (ADR-014 item 2): every live caption fetch — player,
	// watch-page, and timedtext baseUrl — passes the shared per-egress-IP limiter
	// so the scheduler and the click-path cannot collectively trip 429s. Skipped
	// when a.transport is set (the test seam) so fakes are not throttled.
	if a.transport == nil {
		if err := WaitFetchGate(ctx); err != nil {
			return nil, 0, fmt.Errorf("fetch gate %s %s: %w", method, url, err)
		}
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, 0, fmt.Errorf("%s %s: %w", method, url, err)
	}
	defer func() { _ = resp.Body.Close() }()
	b, err := io.ReadAll(io.LimitReader(resp.Body, maxCaptionBytes))
	if err != nil {
		return nil, 0, fmt.Errorf("read %s %s body: %w", method, url, err)
	}
	return b, resp.StatusCode, nil
}

// --- player / caption response shapes ---------------------------------------

type playerRequest struct {
	Context innertubeContext `json:"context"`
	VideoID string           `json:"videoId"`
}

type innertubeContext struct {
	Client innertubeClient `json:"client"`
}

type innertubeClient struct {
	ClientName        string `json:"clientName"`
	ClientVersion     string `json:"clientVersion"`
	AndroidSDKVersion int    `json:"androidSdkVersion,omitempty"`
	HL                string `json:"hl"`
	GL                string `json:"gl"`
}

type playerResponse struct {
	Captions struct {
		PlayerCaptionsTracklistRenderer struct {
			CaptionTracks []captionTrack `json:"captionTracks"`
		} `json:"playerCaptionsTracklistRenderer"`
	} `json:"captions"`
}

type captionTrack struct {
	BaseURL      string `json:"baseUrl"`
	LanguageCode string `json:"languageCode"`
	Kind         string `json:"kind"` // "asr" for auto-generated
}

func (t captionTrack) isASR() bool { return strings.EqualFold(t.Kind, "asr") }

// --- timedtext -> plain text ------------------------------------------------

// timedtextToText reduces a timedtext caption body to plain transcript text. It
// auto-detects the format: json3 (a JSON object), else XML (srv3 <p> cues or the
// legacy <transcript><text> form). Whitespace within a cue is normalised and
// consecutive duplicate lines (common in rolling auto-captions) are collapsed.
func timedtextToText(raw string) string {
	trimmed := strings.TrimSpace(raw)
	if strings.HasPrefix(trimmed, "{") {
		return collapse(json3Lines(trimmed))
	}
	return collapse(xmlLines(trimmed))
}

type json3Body struct {
	Events []struct {
		Segs []struct {
			Utf8 string `json:"utf8"`
		} `json:"segs"`
	} `json:"events"`
}

func json3Lines(raw string) []string {
	var doc json3Body
	if err := json.Unmarshal([]byte(raw), &doc); err != nil {
		return nil
	}
	var lines []string
	for _, ev := range doc.Events {
		var b strings.Builder
		for _, s := range ev.Segs {
			b.WriteString(s.Utf8)
		}
		if line := normalize(b.String()); line != "" {
			lines = append(lines, line)
		}
	}
	return lines
}

type timedtextXML struct {
	Ps []struct {
		Chardata string `xml:",chardata"`
		Segs     []struct {
			Chardata string `xml:",chardata"`
		} `xml:"s"`
	} `xml:"body>p"`
	// Legacy format: <transcript><text start dur>...</text></transcript>.
	Texts []string `xml:"text"`
}

func xmlLines(raw string) []string {
	var doc timedtextXML
	if err := xml.Unmarshal([]byte(raw), &doc); err != nil {
		return nil
	}
	var lines []string
	for _, p := range doc.Ps {
		var b strings.Builder
		b.WriteString(p.Chardata)
		for _, s := range p.Segs {
			b.WriteString(s.Chardata)
		}
		if line := normalize(b.String()); line != "" {
			lines = append(lines, line)
		}
	}
	if len(lines) == 0 {
		for _, t := range doc.Texts {
			if line := normalize(t); line != "" {
				lines = append(lines, line)
			}
		}
	}
	return lines
}

// normalize collapses internal whitespace (incl. intra-cue newlines) to single
// spaces and trims. encoding/xml and encoding/json already decode entities.
func normalize(s string) string {
	return strings.Join(strings.Fields(s), " ")
}

// collapse drops consecutive duplicate lines and joins with newlines.
func collapse(lines []string) string {
	var out []string
	var prev string
	for _, line := range lines {
		if line == prev {
			continue
		}
		out = append(out, line)
		prev = line
	}
	return strings.Join(out, "\n")
}

// extractJSONObject finds marker in body and returns the first balanced JSON
// object that follows it (brace-matched, string-aware).
func extractJSONObject(body []byte, marker string) ([]byte, bool) {
	i := bytes.Index(body, []byte(marker))
	if i < 0 {
		return nil, false
	}
	s := body[i+len(marker):]
	j := bytes.IndexByte(s, '{')
	if j < 0 {
		return nil, false
	}
	s = s[j:]
	depth, inStr, esc := 0, false, false
	for k := 0; k < len(s); k++ {
		c := s[k]
		if inStr {
			switch {
			case esc:
				esc = false
			case c == '\\':
				esc = true
			case c == '"':
				inStr = false
			}
			continue
		}
		switch c {
		case '"':
			inStr = true
		case '{':
			depth++
		case '}':
			depth--
			if depth == 0 {
				return s[:k+1], true
			}
		}
	}
	return nil, false
}
