package media

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/nugget/thane-ai-agent/internal/platform/httpkit"
)

// Config holds settings for the media transcript client.
type Config struct {
	// YtDlpPath is the path to the yt-dlp binary. If empty, the binary
	// is located via exec.LookPath.
	YtDlpPath string

	// CookiesFile is an optional path to a Netscape-format cookie file
	// for accessing auth-required content.
	CookiesFile string

	// CookiesFromBrowser extracts cookies directly from an installed
	// browser (e.g., "chrome", "firefox", "chrome:Profile 1").
	// Passed to yt-dlp's --cookies-from-browser flag.
	CookiesFromBrowser string

	// SubtitleLanguage is the preferred subtitle language code (default "en").
	SubtitleLanguage string

	// MaxTranscriptChars limits the transcript text returned in-context.
	// Longer transcripts are truncated. Default: 50000.
	MaxTranscriptChars int

	// WhisperModel is the Ollama model name for audio transcription
	// fallback when no subtitles are available (default "large-v3").
	WhisperModel string

	// TranscriptDir is the directory for durable transcript storage.
	// Each transcript is saved as a markdown file with YAML frontmatter.
	// If empty, transcripts are returned in-context only.
	TranscriptDir string

	// OllamaURL is the base URL for Ollama API calls (Whisper fallback).
	OllamaURL string
}

// Client retrieves and cleans media transcripts.
type Client struct {
	cfg       Config
	logger    *slog.Logger
	http      *http.Client
	summarize SummarizeFunc
}

// Result holds the fetched transcript and associated metadata.
type Result struct {
	Title            string `json:"title"`
	Channel          string `json:"channel,omitempty"`
	Duration         string `json:"duration,omitempty"`
	UploadDate       string `json:"upload_date,omitempty"`
	Description      string `json:"description,omitempty"`
	Transcript       string `json:"transcript"`
	Source           string `json:"source"`
	ID               string `json:"id"`
	TranscriptPath   string `json:"transcript_path,omitempty"`
	Truncated        bool   `json:"truncated,omitempty"`
	Summarized       bool   `json:"summarized,omitempty"`
	DetailLevel      string `json:"detail_level,omitempty"`
	Focus            string `json:"focus,omitempty"`
	AnalysisGuidance string `json:"analysis_guidance,omitempty"`
}

// New creates a media transcript client. The yt-dlp binary path is
// resolved via Config.YtDlpPath or exec.LookPath.
func New(cfg Config, logger *slog.Logger) *Client {
	if cfg.SubtitleLanguage == "" {
		cfg.SubtitleLanguage = "en"
	}
	if cfg.MaxTranscriptChars == 0 {
		cfg.MaxTranscriptChars = 50000
	}
	if cfg.WhisperModel == "" {
		cfg.WhisperModel = "large-v3"
	}

	// Resolve yt-dlp path.
	if cfg.YtDlpPath == "" {
		if p, err := exec.LookPath("yt-dlp"); err == nil {
			cfg.YtDlpPath = p
		}
	}

	return &Client{
		cfg:    cfg,
		logger: logger,
		http: httpkit.NewClient(
			httpkit.WithTimeout(5 * time.Minute),
		),
	}
}

// SetSummarizer configures the LLM summarization function used for
// map-reduce transcript processing. When nil, the client returns raw
// transcripts regardless of the requested detail level.
func (c *Client) SetSummarizer(fn SummarizeFunc) {
	c.summarize = fn
}

// ytdlpJSON is the subset of yt-dlp --print-json output we parse.
type ytdlpJSON struct {
	ID          string  `json:"id"`
	Title       string  `json:"title"`
	Channel     string  `json:"channel"`
	Uploader    string  `json:"uploader"`
	Duration    float64 `json:"duration"`
	UploadDate  string  `json:"upload_date"`
	Description string  `json:"description"`
	Extractor   string  `json:"extractor_key"`
}

// GetTranscript fetches the transcript for the given media URL.
// It prefers manual subtitles over auto-generated, and falls back to
// Whisper transcription via Ollama when no subtitles are available.
//
// The focus parameter, when non-empty, guides summarization to emphasize
// content related to the topic. The detail parameter controls processing:
// DetailFull returns the raw transcript, DetailSummary produces a map-reduce
// summary, and DetailBrief produces an aggressive ~500-char summary.
func (c *Client) GetTranscript(ctx context.Context, rawURL, language, focus string, detail DetailLevel) (*Result, error) {
	if rawURL == "" {
		return nil, fmt.Errorf("media_transcript: url is required")
	}
	if language == "" {
		language = c.cfg.SubtitleLanguage
	}
	if detail == "" {
		detail = DetailFull
	}
	if c.cfg.YtDlpPath == "" {
		return nil, fmt.Errorf("media_transcript: yt-dlp not found (install yt-dlp or set media.yt_dlp_path)")
	}

	// Create temp dir for subtitle files.
	tmpDir, err := os.MkdirTemp("", "thane-media-*")
	if err != nil {
		return nil, fmt.Errorf("media_transcript: create temp dir: %w", err)
	}
	defer os.RemoveAll(tmpDir)

	// Run yt-dlp to fetch metadata and subtitles.
	meta, err := c.runYtDlp(ctx, rawURL, language, tmpDir)
	if err != nil {
		return nil, fmt.Errorf("media_transcript: yt-dlp: %w", err)
	}

	// Find subtitle file in tmpDir.
	transcript, err := c.findAndCleanSubtitles(tmpDir, meta.ID, language)
	if err != nil {
		c.logger.Warn("no subtitles found, attempting whisper fallback",
			"url", rawURL, "error", err)

		// Whisper fallback: not yet implemented in Phase 1.
		// Return a clear error so the agent knows what happened.
		return nil, fmt.Errorf("media_transcript: no subtitles available for %q (whisper fallback not yet implemented)", rawURL)
	}

	// Build result.
	source, id := extractSource(rawURL)
	if id == "" {
		id = meta.ID
	}

	desc := meta.Description
	if len(desc) > 500 {
		desc = desc[:500]
	}

	result := &Result{
		Title:       meta.Title,
		Channel:     firstNonEmpty(meta.Channel, meta.Uploader),
		Duration:    formatDuration(meta.Duration),
		UploadDate:  formatDate(meta.UploadDate),
		Description: desc,
		Source:      source,
		ID:          id,
	}

	// Save the raw transcript to disk before any truncation or
	// summarization so durable storage always has the full text.
	rawTranscript := transcript
	if c.cfg.TranscriptDir != "" {
		// Temporarily set transcript for saving.
		result.Transcript = rawTranscript
		path, saveErr := c.saveTranscript(result, rawURL)
		if saveErr != nil {
			c.logger.Warn("failed to save transcript",
				"error", saveErr, "url", rawURL)
		} else {
			result.TranscriptPath = path
		}
	}

	// Apply summarization or truncation based on detail level.
	needsSummary := (detail == DetailSummary || detail == DetailBrief) && c.summarize != nil
	if needsSummary {
		summary, sumErr := c.summarizeTranscript(ctx, rawTranscript, focus, detail)
		if sumErr != nil {
			c.logger.Warn("summarization failed, returning truncated transcript",
				"error", sumErr, "url", rawURL, "detail", string(detail))
			// Fall through to truncation.
		} else {
			result.Transcript = summary
			result.Summarized = true
			result.DetailLevel = string(detail)
			if focus != "" {
				result.Focus = focus
			}
			return result, nil
		}
	}

	// Full detail or summarization unavailable/failed: truncate for
	// context window safety.
	if len(rawTranscript) > c.cfg.MaxTranscriptChars {
		rawTranscript = rawTranscript[:c.cfg.MaxTranscriptChars]
		result.Truncated = true
	}
	result.Transcript = rawTranscript

	return result, nil
}

// runYtDlp executes yt-dlp and returns parsed metadata.
func (c *Client) runYtDlp(ctx context.Context, rawURL, language, tmpDir string) (*ytdlpJSON, error) {
	browserCookies := c.cfg.CookiesFromBrowser != ""

	// Build flags first, URL last — yt-dlp treats positional args after
	// the URL as additional URLs, so flags must precede it.
	args := []string{
		"--write-sub",
		"--write-auto-sub",
		"--sub-lang", language,
		"--sub-format", "vtt",
		"--convert-subs", "vtt",
		"--skip-download",
		"--print-json",
		"-o", filepath.Join(tmpDir, "%(id)s"),
	}

	// When browser cookies are configured, omit --no-warnings so
	// cookie extraction failures surface on stderr. Otherwise suppress
	// warnings to keep output clean.
	if !browserCookies {
		args = append(args, "--no-warnings")
	}

	if browserCookies {
		args = append(args, "--cookies-from-browser", c.cfg.CookiesFromBrowser)
	} else if c.cfg.CookiesFile != "" {
		args = append(args, "--cookies", c.cfg.CookiesFile)
	}

	// URL must come last.
	args = append(args, rawURL)

	c.logger.Info("running yt-dlp",
		"url", rawURL,
		"language", language,
	)

	cmd := exec.CommandContext(ctx, c.cfg.YtDlpPath, args...)

	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	if err := cmd.Run(); err != nil {
		errOutput := stderr.String()
		// Check cookie status even on failure — the failure may be
		// caused by a cookie problem.
		if browserCookies {
			c.checkCookieStatus(errOutput)
		}
		if len(errOutput) > 500 {
			errOutput = errOutput[:500]
		}
		return nil, fmt.Errorf("%w: %s", err, errOutput)
	}

	// Surface cookie extraction health after every successful run.
	// Only relevant for --cookies-from-browser; --cookies (file) does
	// not produce extraction summary lines.
	if browserCookies {
		c.checkCookieStatus(stderr.String())
	}

	var meta ytdlpJSON
	if err := json.Unmarshal(stdout.Bytes(), &meta); err != nil {
		return nil, fmt.Errorf("parse yt-dlp output: %w", err)
	}

	return &meta, nil
}

// cookieExtractedRe matches yt-dlp's cookie extraction summary line:
//
//	"Extracted 87 cookies from chrome"
//	"Extracted 0 cookies from chrome (87 could not be decrypted)"
//	"Extracted 42 cookies from chrome:Profile 1"
//
// The browser capture uses (.+?) to handle names with spaces or colons
// (e.g. "chrome:Profile 1"), stopping at the optional failure trailer.
var cookieExtractedRe = regexp.MustCompile(`(?m)Extracted (\d+) cookies from (.+?)(?:\s+\((\d+) could not be decrypted\))?$`)

// cookieDecryptFailRe matches individual decryption failure warnings.
var cookieDecryptFailRe = regexp.MustCompile(`(?i)failed to decrypt cookie`)

// checkCookieStatus scans yt-dlp stderr output for cookie extraction
// messages and logs the results at appropriate severity levels.
//
// Healthy: "Extracted 87 cookies from chrome" → Info with count.
// Degraded: "Extracted 50 cookies from chrome (37 could not be decrypted)" → Warn.
// Failed: "Extracted 0 cookies" or no extraction line at all → Error.
func (c *Client) checkCookieStatus(stderrOutput string) {
	m := cookieExtractedRe.FindStringSubmatch(stderrOutput)
	if m == nil {
		// No extraction summary found — cookie loading likely failed
		// entirely before reaching the extraction phase.
		c.logger.Error("cookie extraction produced no output — cookies may not have loaded",
			"cookies_from_browser", c.cfg.CookiesFromBrowser,
			"cookies_file", c.cfg.CookiesFile,
		)
		return
	}

	count, _ := strconv.Atoi(m[1])
	browser := m[2]
	var failedCount int
	if m[3] != "" {
		failedCount, _ = strconv.Atoi(m[3])
	}

	// Also count individual decryption warning lines for extra signal.
	decryptWarnings := len(cookieDecryptFailRe.FindAllString(stderrOutput, -1))

	switch {
	case count == 0:
		c.logger.Error("cookie extraction returned 0 cookies — authentication will fail",
			"browser", browser,
			"failed_decrypt", failedCount,
			"decrypt_warnings", decryptWarnings,
		)
	case failedCount > 0:
		c.logger.Warn("cookie extraction partially degraded — some cookies could not be decrypted",
			"browser", browser,
			"extracted", count,
			"failed_decrypt", failedCount,
			"decrypt_warnings", decryptWarnings,
		)
	default:
		c.logger.Info("cookie extraction healthy",
			"browser", browser,
			"extracted", count,
		)
	}
}

// findAndCleanSubtitles looks for VTT subtitle files in the temp directory,
// preferring manual subs in the requested language over auto-generated ones.
func (c *Client) findAndCleanSubtitles(tmpDir, videoID, language string) (string, error) {
	// yt-dlp names subtitle files with predictable patterns:
	//   {id}.{lang}.vtt            — manual subtitles
	//   {id}.{lang}.auto.vtt       — auto-generated (some versions)
	// When both --write-sub and --write-auto-sub are used, manual subs
	// take the plain name; auto-subs may include extra suffixes.
	entries, err := os.ReadDir(tmpDir)
	if err != nil {
		return "", fmt.Errorf("read temp dir: %w", err)
	}

	// Classify VTT files by match quality.
	var manualLang, autoLang, otherVTT []string
	langSuffix := "." + language + ".vtt"
	for _, e := range entries {
		name := e.Name()
		if !strings.HasSuffix(name, ".vtt") {
			continue
		}
		path := filepath.Join(tmpDir, name)
		switch {
		case strings.Contains(name, ".auto.") && strings.Contains(name, "."+language+"."):
			autoLang = append(autoLang, path)
		case strings.HasSuffix(name, langSuffix):
			manualLang = append(manualLang, path)
		default:
			otherVTT = append(otherVTT, path)
		}
	}

	// Pick the best available file: manual in requested language first,
	// then auto-generated in requested language, then any VTT as fallback.
	var chosen string
	switch {
	case len(manualLang) > 0:
		chosen = manualLang[0]
	case len(autoLang) > 0:
		chosen = autoLang[0]
	case len(otherVTT) > 0:
		chosen = otherVTT[0]
	default:
		return "", fmt.Errorf("no .vtt files found for %s", videoID)
	}

	raw, err := os.ReadFile(chosen)
	if err != nil {
		return "", fmt.Errorf("read subtitle file: %w", err)
	}

	cleaned := CleanVTTWithParagraphs(string(raw))
	if strings.TrimSpace(cleaned) == "" {
		return "", fmt.Errorf("subtitle file empty after cleaning")
	}

	c.logger.Debug("selected subtitle file",
		"file", filepath.Base(chosen),
		"video_id", videoID,
		"language", language,
	)

	return cleaned, nil
}

// saveTranscript writes the transcript to disk as a markdown file with
// YAML frontmatter. Returns the absolute path to the saved file.
func (c *Client) saveTranscript(r *Result, originalURL string) (string, error) {
	dir := c.cfg.TranscriptDir

	// Expand ~ to home directory.
	if strings.HasPrefix(dir, "~/") {
		home, err := os.UserHomeDir()
		if err == nil {
			dir = filepath.Join(home, dir[2:])
		}
	}

	if err := os.MkdirAll(dir, 0o755); err != nil {
		return "", fmt.Errorf("create transcript dir: %w", err)
	}

	filename := sanitizeFilename(r.Source + "-" + r.ID)
	path := filepath.Join(dir, filename+".md")

	var buf strings.Builder
	buf.WriteString("---\n")
	buf.WriteString(fmt.Sprintf("title: %q\n", r.Title))
	if r.Channel != "" {
		buf.WriteString(fmt.Sprintf("channel: %q\n", r.Channel))
	}
	buf.WriteString(fmt.Sprintf("url: %s\n", originalURL))
	buf.WriteString(fmt.Sprintf("source: %s\n", r.Source))
	if r.UploadDate != "" {
		buf.WriteString(fmt.Sprintf("date: %s\n", r.UploadDate))
	}
	if r.Duration != "" {
		buf.WriteString(fmt.Sprintf("duration: %q\n", r.Duration))
	}
	buf.WriteString(fmt.Sprintf("fetched_at: %s\n", time.Now().UTC().Format(time.RFC3339)))
	buf.WriteString("---\n\n")
	buf.WriteString(r.Transcript)
	buf.WriteString("\n")

	if err := os.WriteFile(path, []byte(buf.String()), 0o644); err != nil {
		return "", fmt.Errorf("write transcript: %w", err)
	}

	return path, nil
}

// extractSource parses a URL to determine the source platform and video ID.
func extractSource(rawURL string) (source, id string) {
	u, err := url.Parse(rawURL)
	if err != nil || u.Hostname() == "" {
		return "unknown", ""
	}

	host := strings.ToLower(u.Hostname())

	switch {
	case strings.Contains(host, "youtube.com") || strings.Contains(host, "youtu.be"):
		source = "youtube"
		if strings.Contains(host, "youtu.be") {
			id = strings.TrimPrefix(u.Path, "/")
		} else {
			id = u.Query().Get("v")
		}
	case strings.Contains(host, "vimeo.com"):
		source = "vimeo"
		id = strings.TrimPrefix(u.Path, "/")
	case strings.Contains(host, "twitch.tv"):
		source = "twitch"
		id = strings.TrimPrefix(u.Path, "/videos/")
	default:
		source = strings.TrimPrefix(host, "www.")
		// Use last path segment as ID.
		parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
		if len(parts) > 0 {
			id = parts[len(parts)-1]
		}
	}

	return source, id
}

// sanitizeFilename replaces characters that are unsafe in filenames.
var unsafeFilenameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)

func sanitizeFilename(s string) string {
	return unsafeFilenameRe.ReplaceAllString(s, "_")
}

// formatDuration converts seconds to "H:MM:SS" or "MM:SS" format.
func formatDuration(seconds float64) string {
	total := int(seconds)
	h := total / 3600
	m := (total % 3600) / 60
	s := total % 60

	if h > 0 {
		return fmt.Sprintf("%d:%02d:%02d", h, m, s)
	}
	return fmt.Sprintf("%d:%02d", m, s)
}

// formatDate converts yt-dlp's "YYYYMMDD" date format to "YYYY-MM-DD".
func formatDate(yyyymmdd string) string {
	if len(yyyymmdd) != 8 {
		return yyyymmdd
	}
	return yyyymmdd[:4] + "-" + yyyymmdd[4:6] + "-" + yyyymmdd[6:8]
}

// firstNonEmpty returns the first non-empty string from the arguments.
func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if v != "" {
			return v
		}
	}
	return ""
}
