package player

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gopxl/beep/v2"
)

// pipeBufSize is the buffer size for audio pipe readers (yt-dlp, ffmpeg).
const pipeBufSize = 64 * 1024

// ytdlPipeTimeout limits how long we wait for yt-dlp to produce initial audio.
const ytdlPipeTimeout = 30 * time.Second

// ytdlCauseGrace bounds how long buildYTDLPipeline waits, after the audio pipe
// closes with no data, for yt-dlp or ffmpeg to report why. yt-dlp typically
// exits quickly with a stderr message (bot wall, 404, DRM); this only matters
// when the process is slow to flush and exit.
const ytdlCauseGrace = 3 * time.Second

// ytdlCookiesFrom is the browser name for --cookies-from-browser (e.g. "chrome").
// Set via SetYTDLCookiesFrom at startup.
var ytdlCookiesFrom string

// SetYTDLCookiesFrom configures yt-dlp to use cookies from the given browser
// for YouTube Music playback (e.g. "chrome", "firefox", "brave").
func SetYTDLCookiesFrom(browser string) {
	ytdlCookiesFrom = browser
}

// YTDLPAvailable reports whether yt-dlp is installed and on PATH.
func YTDLPAvailable() bool {
	_, err := exec.LookPath("yt-dlp")
	return err == nil
}

// probeYTDLDuration runs a quick yt-dlp --print duration to obtain
// the track duration when --flat-playlist didn't provide it.
func probeYTDLDuration(pageURL string) time.Duration {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	args := []string{"--skip-download", "--no-playlist", "--socket-timeout", "10", "--print", "duration"}
	if ytdlCookiesFrom != "" {
		args = append(args, "--cookies-from-browser", ytdlCookiesFrom)
	}
	args = append(args, pageURL)
	cmd := exec.CommandContext(ctx, "yt-dlp", args...)
	// WaitDelay ensures cmd.Output() doesn't hang indefinitely if the
	// process is killed but I/O pipe goroutines haven't drained. Without
	// this, a zombie yt-dlp child keeping stdout open can block Output()
	// forever, which in turn blocks PlayYTDL and leaves the UI stuck at
	// "Buffering...".
	cmd.WaitDelay = 3 * time.Second
	out, err := cmd.Output()
	if err != nil {
		return 0
	}
	secs, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
	if err != nil || secs <= 0 {
		return 0
	}
	return time.Duration(secs * float64(time.Second))
}

// InstallYTDLP attempts to install yt-dlp using the system package manager.
// Returns nil on success. The caller should re-check YTDLPAvailable() after.
func InstallYTDLP() error {
	switch runtime.GOOS {
	case "darwin":
		if _, err := exec.LookPath("brew"); err == nil {
			cmd := exec.Command("brew", "install", "yt-dlp")
			cmd.Stdout = os.Stderr
			cmd.Stderr = os.Stderr
			return cmd.Run()
		}
		// Fall through to pip
	case "linux":
		if _, err := exec.LookPath("apt-get"); err == nil {
			cmd := exec.Command("sudo", "apt-get", "install", "-y", "yt-dlp")
			cmd.Stdout = os.Stderr
			cmd.Stderr = os.Stderr
			return cmd.Run()
		}
		if _, err := exec.LookPath("pacman"); err == nil {
			cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", "yt-dlp")
			cmd.Stdout = os.Stderr
			cmd.Stderr = os.Stderr
			return cmd.Run()
		}
	}
	// Fallback: pip/pipx
	if path, err := exec.LookPath("pipx"); err == nil {
		cmd := exec.Command(path, "install", "yt-dlp")
		cmd.Stdout = os.Stderr
		cmd.Stderr = os.Stderr
		return cmd.Run()
	}
	if path, err := exec.LookPath("pip3"); err == nil {
		cmd := exec.Command(path, "install", "yt-dlp")
		cmd.Stdout = os.Stderr
		cmd.Stderr = os.Stderr
		return cmd.Run()
	}
	return fmt.Errorf("no supported package manager found — install manually: https://github.com/yt-dlp/yt-dlp#installation")
}

// YtdlpInstallHint returns a platform-specific install command suggestion.
func YtdlpInstallHint() string {
	switch runtime.GOOS {
	case "darwin":
		return "brew install yt-dlp"
	case "linux":
		if _, err := exec.LookPath("apt-get"); err == nil {
			return "sudo apt install yt-dlp"
		}
		if _, err := exec.LookPath("pacman"); err == nil {
			return "sudo pacman -S yt-dlp"
		}
		return "pip install yt-dlp"
	case "windows":
		return "winget install yt-dlp"
	default:
		return "pip install yt-dlp"
	}
}

// ffmpegInstallHint returns a platform-specific install command suggestion.
func ffmpegInstallHint() string {
	switch runtime.GOOS {
	case "darwin":
		return "brew install ffmpeg"
	case "linux":
		if _, err := exec.LookPath("apt-get"); err == nil {
			return "sudo apt install ffmpeg"
		}
		if _, err := exec.LookPath("pacman"); err == nil {
			return "sudo pacman -S ffmpeg"
		}
		return "see https://ffmpeg.org/download.html"
	case "windows":
		return "winget install ffmpeg"
	default:
		return "see https://ffmpeg.org/download.html"
	}
}

// ytdlPipeStreamer streams PCM audio from a yt-dlp | ffmpeg pipe chain.
// yt-dlp downloads the best audio and writes raw data to stdout; ffmpeg reads
// that via a pipe and converts it to PCM on its stdout, which we consume.
type ytdlPipeStreamer struct {
	ytdlCmd   *exec.Cmd
	ffmpegCmd *exec.Cmd
	pipe      io.ReadCloser // ffmpeg stdout (PCM output)
	reader    *bufio.Reader // buffered reader over pipe
	ytdlErr   <-chan error  // yt-dlp exit error from monitoring goroutine
	ffmpegErr <-chan error  // ffmpeg exit error from monitoring goroutine
	buf       [pcmFrameSize32]byte
	f32       bool // true = f32le, false = s16le
	pos       int  // samples consumed so far
	closeOnce sync.Once
	err       error
}

func (y *ytdlPipeStreamer) Stream(samples [][2]float64) (int, bool) {
	n, ok := streamFromReader(y.reader, samples, y.buf[:], y.f32, &y.err)
	y.pos += n
	// On EOF with no frames read, surface why the pipe closed (yt-dlp bot
	// wall, 404, DRM, or undecodable ffmpeg input) instead of a bare EOF.
	if n == 0 && y.err == nil {
		if cause := y.waitCause(0); cause != nil {
			y.err = cause
		}
	}
	return n, ok
}

// waitCause reports why the audio pipe closed without producing audio,
// preferring yt-dlp's reason (bot wall, 404, DRM, region block) over ffmpeg's
// (undecodable input). With d <= 0 it polls without blocking; otherwise it
// waits up to d for a process to report. Returns nil if neither reported an
// error, leaving the caller to surface the bare EOF.
func (y *ytdlPipeStreamer) waitCause(d time.Duration) error {
	if d <= 0 {
		select {
		case e := <-y.ytdlErr:
			if e != nil {
				return e
			}
		default:
		}
		select {
		case e := <-y.ffmpegErr:
			return e
		default:
			return nil
		}
	}
	deadline := time.After(d)
	ytdlDone, ffmpegDone := false, false
	var ffErr error
	for !ytdlDone || !ffmpegDone {
		select {
		case e := <-y.ytdlErr:
			ytdlDone = true
			if e != nil {
				return e
			}
		case e := <-y.ffmpegErr:
			ffmpegDone = true
			ffErr = e
		case <-deadline:
			return ffErr
		}
	}
	return ffErr
}

func (y *ytdlPipeStreamer) Err() error     { return y.err }
func (y *ytdlPipeStreamer) Len() int       { return 0 }
func (y *ytdlPipeStreamer) Position() int  { return y.pos }
func (y *ytdlPipeStreamer) Seek(int) error { return nil }

func (y *ytdlPipeStreamer) Close() error {
	y.closeOnce.Do(func() {
		// Kill both processes to stop downloading/decoding.
		if y.ytdlCmd.Process != nil {
			y.ytdlCmd.Process.Kill()
		}
		if y.ffmpegCmd.Process != nil {
			y.ffmpegCmd.Process.Kill()
		}
		y.pipe.Close()
		// The yt-dlp and ffmpeg monitor goroutines own Wait() on their
		// processes; killing the processes above makes those Waits return.
		// Both error channels are buffered, so the monitors send and exit
		// without a receiver — no draining needed here.
	})
	return nil
}

// monitorExit waits for cmd to exit and reports the result on a buffered
// channel: a wrapped error preferring captured stderr over the bare exit code
// on failure, or nil on clean exit. The channel is buffered so the goroutine
// always completes even with no receiver (e.g. after Close kills the process).
func monitorExit(cmd *exec.Cmd, stderr *bytes.Buffer, name string) <-chan error {
	ch := make(chan error, 1)
	go func() {
		err := cmd.Wait()
		switch trimmed := bytes.TrimSpace(stderr.Bytes()); {
		case err == nil:
			ch <- nil
		case len(trimmed) > 0:
			ch <- fmt.Errorf("%s: %s", name, trimmed)
		default:
			ch <- fmt.Errorf("%s: %w", name, err)
		}
	}()
	return ch
}

// decodeYTDLPipe starts a yt-dlp | ffmpeg pipe chain for the given page URL
// and returns a streaming PCM decoder. If startSec > 0, ffmpeg -ss is used
// to skip to the desired position in the input stream.
func decodeYTDLPipe(pageURL string, sr beep.SampleRate, bitDepth, startSec int) (*ytdlPipeStreamer, beep.Format, error) {
	if _, err := exec.LookPath("yt-dlp"); err != nil {
		return nil, beep.Format{}, fmt.Errorf("yt-dlp is required — install: %s", YtdlpInstallHint())
	}
	if _, err := exec.LookPath("ffmpeg"); err != nil {
		return nil, beep.Format{}, fmt.Errorf("ffmpeg is required — install: %s", ffmpegInstallHint())
	}

	// os.Pipe connects yt-dlp stdout → ffmpeg stdin.
	pr, pw, err := os.Pipe()
	if err != nil {
		return nil, beep.Format{}, fmt.Errorf("os.Pipe: %w", err)
	}

	// Start yt-dlp: download best audio to stdout.
	// Prefer direct HTTPS/HTTP streams over HLS (m3u8). HLS requires segment
	// downloading and muxing which doesn't pipe cleanly to stdout.
	// Live streams (e.g. YouTube live) expose no audio-only formats at all,
	// only muxed video+audio over HLS, so fall back to "best" as a last
	// resort; the ffmpeg stage below outputs PCM audio and drops the video.
	ytdlArgs := []string{
		"-f", "bestaudio[protocol=https]/bestaudio[protocol=http]/bestaudio[protocol!=m3u8_native][protocol!=m3u8]/bestaudio/best",
		"--no-playlist",
		"--quiet",
		"--no-warnings",
		"--socket-timeout", "15",
		"-o", "-",
	}
	if ytdlCookiesFrom != "" {
		ytdlArgs = append(ytdlArgs, "--cookies-from-browser", ytdlCookiesFrom)
	}
	ytdlArgs = append(ytdlArgs, pageURL)
	ytdlCmd := exec.Command("yt-dlp", ytdlArgs...)
	ytdlCmd.Stdout = pw
	var ytdlStderr bytes.Buffer
	ytdlCmd.Stderr = &ytdlStderr
	if err := ytdlCmd.Start(); err != nil {
		pr.Close()
		pw.Close()
		return nil, beep.Format{}, fmt.Errorf("yt-dlp start: %w", err)
	}

	// Start ffmpeg: read from pipe, output PCM to stdout.
	// If startSec > 0, use -ss to seek into the input stream.
	pcmFmt, codec, precision := ffmpegPCMArgs(bitDepth)
	var ffmpegArgs []string
	if startSec > 0 {
		ffmpegArgs = append(ffmpegArgs, "-ss", strconv.Itoa(startSec))
	}
	ffmpegArgs = append(ffmpegArgs,
		"-i", "pipe:0",
		"-f", pcmFmt,
		"-acodec", codec,
		"-ar", strconv.Itoa(int(sr)),
		"-ac", "2",
		"-loglevel", "error",
		"pipe:1",
	)
	ffmpegCmd := exec.Command("ffmpeg", ffmpegArgs...)
	ffmpegCmd.Stdin = pr
	var ffmpegStderr bytes.Buffer
	ffmpegCmd.Stderr = &ffmpegStderr
	ffmpegPipe, err := ffmpegCmd.StdoutPipe()
	if err != nil {
		pw.Close()
		pr.Close()
		ytdlCmd.Process.Kill()
		ytdlCmd.Wait()
		return nil, beep.Format{}, fmt.Errorf("ffmpeg stdout pipe: %w", err)
	}
	if err := ffmpegCmd.Start(); err != nil {
		pw.Close()
		pr.Close()
		ytdlCmd.Process.Kill()
		ytdlCmd.Wait()
		return nil, beep.Format{}, fmt.Errorf("ffmpeg start: %w", err)
	}

	// Close parent's copies of pipe ends. yt-dlp owns pw (write end) and
	// ffmpeg owns pr (read end). If the parent keeps these open, EOF won't
	// propagate when the owning process exits.
	pw.Close()
	pr.Close()

	// Monitor each process's exit so we can surface why the pipe closed. A
	// process's stderr is only safe to read after Wait() returns, so the
	// capture happens inside monitorExit.
	ytdlErrCh := monitorExit(ytdlCmd, &ytdlStderr, "yt-dlp")
	ffmpegErrCh := monitorExit(ffmpegCmd, &ffmpegStderr, "ffmpeg")

	format := beep.Format{
		SampleRate:  sr,
		NumChannels: 2,
		Precision:   precision,
	}

	return &ytdlPipeStreamer{
		ytdlCmd:   ytdlCmd,
		ffmpegCmd: ffmpegCmd,
		pipe:      ffmpegPipe,
		reader:    bufio.NewReaderSize(ffmpegPipe, pipeBufSize),
		ytdlErr:   ytdlErrCh,
		ffmpegErr: ffmpegErrCh,
		f32:       bitDepth == 32,
	}, format, nil
}

// buildYTDLPipeline creates a trackPipeline for a yt-dlp URL.
// If startSec > 0, playback begins at that offset (seek-by-restart).
func (p *Player) buildYTDLPipeline(pageURL string, startSec int) (*trackPipeline, error) {
	p.streamTitle.Store("")

	decoder, format, err := decodeYTDLPipe(pageURL, p.sr, p.bitDepth, startSec)
	if err != nil {
		return nil, err
	}

	// Pre-fill: block until yt-dlp + ffmpeg produce initial audio data.
	// This runs in a tea.Cmd goroutine (not the UI thread), ensuring the
	// speaker goroutine won't block on an empty pipe and hold its lock
	// (which would freeze the UI). A 30s timeout prevents hanging when
	// yt-dlp is slow to produce output.
	peekErr := make(chan error, 1)
	go func() {
		_, err := decoder.reader.Peek(1)
		peekErr <- err
	}()
	select {
	case err := <-peekErr:
		if err != nil {
			// The audio pipe closed before producing a byte. Prefer the real
			// cause from yt-dlp (e.g. "Sign in to confirm you're not a bot",
			// "HTTP Error 404", DRM, region block) or ffmpeg over the opaque
			// EOF — the pipe only tells us the upstream closed, not why.
			cause := decoder.waitCause(ytdlCauseGrace)
			decoder.Close()
			if cause != nil {
				return nil, cause
			}
			return nil, fmt.Errorf("waiting for audio data: %w", err)
		}
	case <-time.After(ytdlPipeTimeout):
		decoder.Close()
		<-peekErr // drain goroutine after Close() unblocks the pipe
		return nil, fmt.Errorf("timed out waiting for audio data (%v)", ytdlPipeTimeout)
	}

	return &trackPipeline{
		decoder:      decoder,
		stream:       decoder,
		format:       format,
		seekable:     false,
		path:         pageURL,
		ytdlSeek:     true,
		streamOffset: time.Duration(startSec) * time.Second,
	}, nil
}
