// Package player startet und ueberwacht mpv und haelt dessen Playliste aktuell.
//
// mpv bekommt die komplette Playliste (Bilder und Videos gemischt) und schaltet
// selbst weiter; dieses Programm laedt sie nur nach, wenn sich der Medienordner
// geaendert hat - und zwar zwischen zwei Dateien, damit kein laufendes Video
// unterbrochen wird.
package player

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"

	"ehc/tvwall/internal/config"
	"ehc/tvwall/internal/mpv"
	"ehc/tvwall/internal/playlist"
)

// IDs fuer die beobachteten mpv-Eigenschaften.
const (
	propPath = iota + 1
	propPause
	propPlaylistPos
	propIdle
	propTimePos
)

const tickInterval = 5 * time.Second

// Status beschreibt den Zustand der Wiedergabe fuer das Webinterface.
type Status struct {
	Running       bool   `json:"running"`
	Idle          bool   `json:"idle"`
	Paused        bool   `json:"paused"`
	Current       string `json:"current"`
	Position      int    `json:"position"`
	Display       string `json:"display"`
	PendingReload bool   `json:"pending_reload"`
	LastError     string `json:"last_error"`
}

// Player haelt genau einen mpv-Prozess am Leben.
type Player struct {
	cfg      *config.Config
	pl       *playlist.Manager
	settings *config.Store

	mu       sync.RWMutex
	client   *mpv.Client
	status   Status
	pending  bool
	loadedID uint64 // Version der zuletzt an mpv uebergebenen Playliste

	lastTimePos    float64
	lastProgressAt time.Time

	bootLogoDone bool // Startlogo wurde in dieser Dienstlaufzeit schon gezeigt

	reloadNow chan struct{}
	restart   chan struct{}
}

// New erzeugt den Player.
func New(cfg *config.Config, pl *playlist.Manager, settings *config.Store) *Player {
	return &Player{
		cfg:       cfg,
		pl:        pl,
		settings:  settings,
		reloadNow: make(chan struct{}, 1),
		restart:   make(chan struct{}, 1),
	}
}

// Status liefert den aktuellen Zustand.
func (p *Player) Status() Status {
	p.mu.RLock()
	defer p.mu.RUnlock()
	s := p.status
	s.PendingReload = p.pending
	return s
}

// Run laeuft bis ctx abgebrochen wird und startet mpv bei Bedarf neu.
func (p *Player) Run(ctx context.Context) {
	settingsChanged := p.settings.Subscribe()
	backoff := time.Second
	for ctx.Err() == nil {
		start := time.Now()
		if err := p.runOnce(ctx, settingsChanged); err != nil && ctx.Err() == nil {
			log.Printf("player: %v", err)
			p.setError(err.Error())
		}
		p.setRunning(false)
		if ctx.Err() != nil {
			return
		}
		if time.Since(start) > time.Minute {
			backoff = time.Second
		} else if backoff < 15*time.Second {
			backoff *= 2
		}
		log.Printf("player: neustart in %s", backoff)
		select {
		case <-ctx.Done():
			return
		case <-time.After(backoff):
		}
	}
}

func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) error {
	disp, err := resolveDisplay(p.cfg.DisplayMode)
	if err != nil {
		return err
	}
	disp.logDisplay()

	sock := p.cfg.MpvSocket()
	if err := os.MkdirAll(filepath.Dir(sock), 0o775); err != nil {
		return fmt.Errorf("socket-verzeichnis: %w", err)
	}
	_ = os.Remove(sock)

	// Die Playliste wird mpv direkt auf der Kommandozeile mitgegeben. Sonst
	// muesste erst die IPC-Verbindung stehen, bevor ueberhaupt etwas zu sehen
	// ist - das kostet beim Start rund eine Sekunde, in der die Textkonsole
	// sichtbar wird.
	entries, version := p.pl.EntriesWithVersion()

	// Startlogo: beim ersten mpv-Start dieser Dienstlaufzeit bekommt mpv das
	// Logo als erste Datei. So ist es das allererste Bild auf dem Schirm.
	startLogo := ""
	p.mu.RLock()
	logoGezeigt := p.bootLogoDone
	p.mu.RUnlock()
	if !logoGezeigt && p.cfg.BootLogo != "" && p.cfg.BootLogoSeconds > 0 {
		if _, err := os.Stat(p.cfg.BootLogo); err == nil {
			startLogo = p.cfg.BootLogo
		} else {
			log.Printf("player: startlogo %q nicht lesbar: %v", p.cfg.BootLogo, err)
		}
	}

	aktive := p.pl.ActiveCount()
	args := p.mpvArgs(disp, aktive, startLogo)
	log.Printf("player: starte mpv %s", strings.Join(args, " "))

	cmd := exec.Command("mpv", args...)
	// stdin durchreichen: bei --gpu-context=drm braucht mpv das Terminal der
	// Konsole, um den Bildschirm uebernehmen zu duerfen.
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Env = append(os.Environ(), disp.env...)
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("mpv starten: %w (ist mpv installiert?)", err)
	}

	var waitErr error
	exited := make(chan struct{})
	go func() {
		waitErr = cmd.Wait()
		close(exited)
	}()
	defer func() {
		if cmd.Process != nil {
			_ = cmd.Process.Kill()
		}
		<-exited
		_ = os.Remove(sock)
	}()

	client, err := mpv.Dial(sock, 15*time.Second)
	if err != nil {
		return err
	}
	defer client.Close()

	p.mu.Lock()
	p.client = client
	p.status = Status{Running: true, Display: disp.mode}
	p.lastTimePos = -1
	p.lastProgressAt = time.Now()
	p.mu.Unlock()
	defer func() {
		p.mu.Lock()
		p.client = nil
		p.mu.Unlock()
	}()

	for id, name := range map[int]string{
		propPath: "path", propPause: "pause", propPlaylistPos: "playlist-pos",
		propIdle: "idle-active", propTimePos: "time-pos",
	} {
		if err := client.ObserveProperty(id, name); err != nil {
			log.Printf("player: observe %s: %v", name, err)
		}
	}

	var logoTimer *time.Timer
	switch {
	case startLogo != "":
		// mpv zeigt das Logo bereits - nur den Zeitpunkt merken, an dem auf
		// die Playliste umgeschaltet wird. applySettings kaeme der
		// Anzeigedauer des Logos in die Quere und folgt deshalb spaeter.
		p.mu.Lock()
		p.bootLogoDone = true
		p.status.Current = filepath.Base(startLogo)
		p.mu.Unlock()
		logoTimer = time.NewTimer(time.Duration(p.cfg.BootLogoSeconds * float64(time.Second)))
		log.Printf("player: startlogo %s fuer %.0f s", filepath.Base(startLogo), p.cfg.BootLogoSeconds)

	case aktive > 0:
		// Playliste lief schon beim Start von mpv mit - nur den Stand merken.
		p.applySettings(client)
		p.mu.Lock()
		p.loadedID = version
		p.pending = false
		p.mu.Unlock()
		log.Printf("player: playliste beim start uebergeben (%d von %d eintraegen aktiv)",
			aktive, len(entries))

	default:
		p.applySettings(client)
		p.loadPlaylist(client)
	}
	p.setError("")

	ticker := time.NewTicker(tickInterval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return nil

		case <-exited:
			return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)

		case <-client.Done():
			select {
			case <-exited:
				return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)
			case <-time.After(time.Second):
				return fmt.Errorf("ipc-verbindung verloren")
			}

		case <-p.pl.Changed():
			// Nicht mitten in einer Datei neu laden - erst am Uebergang.
			p.mu.Lock()
			p.pending = p.pl.Version() != p.loadedID
			pending, idle := p.pending, p.status.Idle
			p.mu.Unlock()
			if pending && idle {
				p.loadPlaylist(client)
			}

		case <-p.reloadNow:
			p.loadPlaylist(client)

		case <-p.restart:
			return fmt.Errorf("neustart angefordert")

		case <-timerC(logoTimer):
			logoTimer.Stop()
			logoTimer = nil
			p.applySettings(client) // Anzeigedauer wieder auf den Normalwert
			p.loadPlaylist(client)

		case <-settingsChanged:
			p.applySettings(client)

		case <-ticker.C:
			p.checkStalled(client)

		case ev, ok := <-client.Events():
			if !ok {
				return fmt.Errorf("ipc-verbindung beendet")
			}
			p.handleEvent(client, ev)
		}
	}
}

func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) {
	switch ev.Name {
	case "property-change":
		p.mu.Lock()
		switch ev.ID {
		case propPath:
			var s string
			if json.Unmarshal(ev.Data, &s) == nil && s != "" {
				p.status.Current = filepath.Base(s)
			} else {
				p.status.Current = ""
			}
			p.lastTimePos = -1
			p.lastProgressAt = time.Now()
		case propPause:
			var b bool
			if json.Unmarshal(ev.Data, &b) == nil {
				p.status.Paused = b
			}
		case propPlaylistPos:
			var n float64
			if json.Unmarshal(ev.Data, &n) == nil {
				p.status.Position = int(n)
			}
		case propIdle:
			var b bool
			if json.Unmarshal(ev.Data, &b) == nil {
				p.status.Idle = b
			}
		case propTimePos:
			var pos float64
			if json.Unmarshal(ev.Data, &pos) == nil && pos != p.lastTimePos {
				p.lastTimePos = pos
				p.lastProgressAt = time.Now()
			}
		}
		idle, pending := p.status.Idle, p.pending
		p.mu.Unlock()

		if ev.ID == propIdle && idle && pending {
			p.loadPlaylist(client)
		}

	case "end-file":
		// Der beste Moment fuer eine neue Playliste: zwischen zwei Dateien.
		p.mu.RLock()
		pending := p.pending
		p.mu.RUnlock()
		if pending {
			p.loadPlaylist(client)
		}
	}
}

// timerC liefert den Kanal eines Timers - oder nil, wenn keiner laeuft.
// Ein nil-Kanal blockiert im select-Zweig fuer immer, genau wie gewuenscht.
func timerC(t *time.Timer) <-chan time.Time {
	if t == nil {
		return nil
	}
	return t.C
}

// loadPlaylist uebergibt mpv die aktuelle m3u-Datei.
func (p *Player) loadPlaylist(client *mpv.Client) {
	_, version := p.pl.EntriesWithVersion()
	aktive := p.pl.ActiveCount()

	p.mu.Lock()
	p.pending = false
	p.loadedID = version
	p.mu.Unlock()

	if aktive == 0 {
		if _, err := client.Command("playlist-clear"); err != nil {
			log.Printf("player: playlist-clear: %v", err)
		}
		if _, err := client.Command("stop"); err != nil {
			log.Printf("player: stop: %v", err)
		}
		p.mu.Lock()
		p.status.Current = ""
		p.mu.Unlock()
		return
	}

	if _, err := client.Command("loadlist", p.pl.PlaylistFile(), "replace"); err != nil {
		log.Printf("player: playlist laden: %v", err)
		p.setError("Playliste laden: " + err.Error())
		return
	}
	if err := client.SetProperty("pause", false); err != nil {
		log.Printf("player: fortsetzen: %v", err)
	}
	log.Printf("player: playliste geladen (%d eintraege)", aktive)
}

// checkStalled ueberspringt ein Video, das nicht mehr weiterlaeuft, damit eine
// einzelne Datei nicht die ganze Wiedergabe anhaelt.
func (p *Player) checkStalled(client *mpv.Client) {
	timeout := time.Duration(p.cfg.StallTimeout) * time.Second
	if timeout <= 0 {
		return
	}
	p.mu.RLock()
	current, paused, idle := p.status.Current, p.status.Paused, p.status.Idle
	since := time.Since(p.lastProgressAt)
	p.mu.RUnlock()

	// Bilder haben keinen fortlaufenden Zeitstempel - nur Videos pruefen.
	if paused || idle || current == "" || playlist.Kind(current) != "video" || since < timeout {
		return
	}

	log.Printf("player: %q laeuft seit %s nicht weiter - wird uebersprungen",
		current, since.Round(time.Second))
	p.setError(fmt.Sprintf("%s bleibt haengen und wurde uebersprungen", current))
	p.mu.Lock()
	p.lastProgressAt = time.Now()
	p.mu.Unlock()
	if _, err := client.Command("playlist-next", "force"); err != nil {
		log.Printf("player: weiterschalten: %v", err)
	}
}

// applySettings uebernimmt Anzeigedauer und Stummschaltung ohne mpv-Neustart.
func (p *Player) applySettings(client *mpv.Client) {
	s := p.settings.Get()
	if err := client.SetProperty("image-display-duration", s.ImageDuration); err != nil {
		log.Printf("player: anzeigedauer setzen: %v", err)
	}
	if p.cfg.Audio {
		if err := client.SetProperty("mute", s.Mute); err != nil {
			log.Printf("player: stummschaltung setzen: %v", err)
		}
	}
}

func (p *Player) mpvArgs(disp display, aktive int, startLogo string) []string {
	s := p.settings.Get()
	anzeigedauer := s.ImageDuration
	if startLogo != "" {
		// Etwas laenger als bis zum Umschalten, damit keine Luecke entsteht.
		anzeigedauer = p.cfg.BootLogoSeconds + 2
	}
	args := []string{
		"--input-ipc-server=" + p.cfg.MpvSocket(),
		"--fullscreen",
		"--idle=yes",
		"--force-window=yes",
		"--keep-open=no",
		"--loop-playlist=inf",
		"--image-display-duration=" + strconv.FormatFloat(anzeigedauer, 'f', -1, 64),
		"--hwdec=" + p.cfg.MpvHwdec,
		"--no-osc",
		"--osd-level=0",
		"--cursor-autohide=always",
		"--no-input-default-bindings",
		"--input-vo-keyboard=no",
		"--stop-screensaver=yes",
		// Kein --no-terminal: sonst verschluckt mpv seine Fehlermeldungen.
		"--msg-level=all=warn",
		"--no-input-terminal",
		"--vo=gpu",
	}
	// Die Vulkan-Anbindung von libplacebo baut bei jedem Bildwechsel die
	// Swapchain neu auf und laeuft dabei nach wenigen Wechseln in
	// VK_ERROR_OUT_OF_HOST_MEMORY: mpv laeuft weiter, der Bildschirm friert
	// ein. Mit OpenGL tritt das nicht auf (auf einem Pi 5 nachgemessen:
	// 0 statt 24 Fehlern in 20 Bildwechseln).
	if p.cfg.MpvGpuAPI != "" && p.cfg.MpvGpuAPI != "auto" {
		args = append(args, "--gpu-api="+p.cfg.MpvGpuAPI)
	}
	if p.cfg.Audio {
		args = append(args, "--mute="+yesNo(s.Mute))
	} else {
		args = append(args, "--no-audio")
	}
	if disp.mode == "drm" {
		args = append(args, "--gpu-context=drm")
	}
	args = append(args, splitArgs(p.cfg.MpvExtraArgs)...)
	// Die erste Datei bekommt mpv direkt mit: so erscheint das Bild, sobald
	// mpv den Bildschirm hat, ohne Umweg ueber die IPC-Verbindung.
	switch {
	case startLogo != "":
		args = append(args, startLogo)
	case aktive > 0:
		args = append(args, "--playlist="+p.pl.PlaylistFile())
	}
	return args
}

func yesNo(b bool) string {
	if b {
		return "yes"
	}
	return "no"
}

// splitArgs zerlegt eine Optionszeile und respektiert dabei Anfuehrungszeichen.
func splitArgs(s string) []string {
	var out []string
	var cur strings.Builder
	var quote rune
	for _, r := range s {
		switch {
		case quote != 0:
			if r == quote {
				quote = 0
			} else {
				cur.WriteRune(r)
			}
		case r == '"' || r == '\'':
			quote = r
		case r == ' ' || r == '\t':
			if cur.Len() > 0 {
				out = append(out, cur.String())
				cur.Reset()
			}
		default:
			cur.WriteRune(r)
		}
	}
	if cur.Len() > 0 {
		out = append(out, cur.String())
	}
	return out
}

// ---------------------------------------------------------------------------
// Steuerung aus dem Webinterface
// ---------------------------------------------------------------------------

func (p *Player) withClient(fn func(*mpv.Client) error) error {
	p.mu.RLock()
	c := p.client
	p.mu.RUnlock()
	if c == nil {
		return fmt.Errorf("player laeuft gerade nicht")
	}
	return fn(c)
}

// Next springt zum naechsten Eintrag.
func (p *Player) Next() error {
	return p.withClient(func(c *mpv.Client) error {
		_, err := c.Command("playlist-next", "force")
		return err
	})
}

// Prev springt zum vorherigen Eintrag.
func (p *Player) Prev() error {
	return p.withClient(func(c *mpv.Client) error {
		_, err := c.Command("playlist-prev", "force")
		return err
	})
}

// TogglePause haelt die Wiedergabe an oder setzt sie fort.
func (p *Player) TogglePause() error {
	return p.withClient(func(c *mpv.Client) error {
		_, err := c.Command("cycle", "pause")
		return err
	})
}

// ReloadNow laedt die Playliste sofort neu, auch mitten in einem Video.
func (p *Player) ReloadNow() error {
	select {
	case p.reloadNow <- struct{}{}:
	default:
	}
	return nil
}

// Restart startet den mpv-Prozess komplett neu.
func (p *Player) Restart() error {
	select {
	case p.restart <- struct{}{}:
	default:
	}
	return nil
}

func (p *Player) setRunning(v bool) {
	p.mu.Lock()
	p.status.Running = v
	if !v {
		p.status.Current = ""
	}
	p.mu.Unlock()
}

func (p *Player) setError(msg string) {
	p.mu.Lock()
	p.status.LastError = msg
	p.mu.Unlock()
}
