// Package playlist verwaltet den Medienordner: regelmaessiges Einlesen,
// persistente Sortierung und das Schreiben der m3u-Datei fuer mpv.
package playlist

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"

	"ehc/tvwall/internal/config"
)

// Unterstuetzte Dateiendungen.
var (
	imageExt = map[string]bool{
		".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
		".webp": true, ".tif": true, ".tiff": true,
	}
	videoExt = map[string]bool{
		".mp4": true, ".m4v": true, ".mov": true, ".mkv": true, ".avi": true,
		".webm": true, ".mpg": true, ".mpeg": true, ".wmv": true, ".flv": true,
		".ts": true, ".3gp": true, ".ogv": true,
	}
)

// Formate, die mpv nicht zuverlaessig anzeigt, die aber beim Hochladen
// angenommen und in JPEG umgewandelt werden. iPhones liefern HEIC.
var convertExt = map[string]bool{".heic": true, ".heif": true}

// Convertible meldet, ob die Datei beim Hochladen umgewandelt werden muss.
func Convertible(name string) bool {
	return convertExt[strings.ToLower(filepath.Ext(name))]
}

// UploadAllowed meldet, ob die Datei hochgeladen werden darf - entweder direkt
// abspielbar oder umwandelbar.
func UploadAllowed(name string) bool { return Supported(name) || Convertible(name) }

// Kind liefert "image", "video" oder "" fuer nicht unterstuetzte Dateien.
func Kind(name string) string {
	ext := strings.ToLower(filepath.Ext(name))
	switch {
	case imageExt[ext]:
		return "image"
	case videoExt[ext]:
		return "video"
	}
	return ""
}

// Supported meldet, ob die Datei abgespielt werden kann.
func Supported(name string) bool { return Kind(name) != "" }

// Entry ist ein Eintrag der Playliste.
type Entry struct {
	Name  string `json:"name"`
	Kind  string `json:"kind"`
	Size  int64  `json:"size"`
	MTime int64  `json:"mtime"`
	// Enabled=false: Die Datei bleibt liegen und steht im Webinterface, wird
	// auf der Wand aber nicht gezeigt.
	Enabled bool `json:"enabled"`
}

// Manager haelt den aktuellen Zustand der Playliste.
type Manager struct {
	mediaDir     string
	orderFile    string
	disabledFile string
	playlistFile string

	mu      sync.RWMutex
	entries []Entry
	version uint64

	changed chan struct{}
}

// New erzeugt den Manager. Scan() muss danach mindestens einmal laufen.
func New(cfg *config.Config) *Manager {
	return &Manager{
		mediaDir:     cfg.MediaDir(),
		orderFile:    cfg.OrderFile(),
		disabledFile: cfg.DisabledFile(),
		playlistFile: cfg.PlaylistFile(),
		changed:      make(chan struct{}, 1),
	}
}

// MediaDir liefert den Medienordner.
func (m *Manager) MediaDir() string { return m.mediaDir }

// PlaylistFile liefert den Pfad der von mpv geladenen m3u-Datei.
func (m *Manager) PlaylistFile() string { return m.playlistFile }

// Changed liefert den Kanal, ueber den Aenderungen gemeldet werden.
func (m *Manager) Changed() <-chan struct{} { return m.changed }

// Entries liefert eine Kopie der aktuellen Playliste.
func (m *Manager) Entries() []Entry {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return append([]Entry(nil), m.entries...)
}

// EntriesWithVersion liefert Playliste und Version in einem Rutsch, damit der
// Player beides konsistent zueinander sieht.
func (m *Manager) EntriesWithVersion() ([]Entry, uint64) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return append([]Entry(nil), m.entries...), m.version
}

// Version steigt bei jeder Aenderung - praktisch fuer das Polling im Webinterface.
func (m *Manager) Version() uint64 {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.version
}

// Scan liest den Medienordner neu ein, mischt die gespeicherte Sortierung
// hinein und schreibt bei Aenderungen die m3u-Datei. Neue Dateien landen
// (nach Aenderungsdatum sortiert) am Ende der Playliste.
func (m *Manager) Scan() (bool, error) {
	if err := os.MkdirAll(m.mediaDir, 0o775); err != nil {
		return false, err
	}
	dirEntries, err := os.ReadDir(m.mediaDir)
	if err != nil {
		return false, err
	}

	ausgeblendet := m.readDisabled()
	found := make(map[string]Entry, len(dirEntries))
	var fresh []Entry
	for _, de := range dirEntries {
		name := de.Name()
		if de.IsDir() || strings.HasPrefix(name, ".") || !Supported(name) {
			continue
		}
		info, err := de.Info()
		if err != nil {
			continue
		}
		e := Entry{
			Name: name, Kind: Kind(name),
			Size: info.Size(), MTime: info.ModTime().Unix(),
			Enabled: !ausgeblendet[name],
		}
		found[name] = e
		fresh = append(fresh, e)
	}

	// Neue Dateien: aeltestes zuerst, damit die Reihenfolge dem Upload folgt.
	sort.Slice(fresh, func(i, j int) bool {
		if fresh[i].MTime != fresh[j].MTime {
			return fresh[i].MTime < fresh[j].MTime
		}
		return fresh[i].Name < fresh[j].Name
	})

	stored := m.readOrder()
	seen := make(map[string]bool, len(found))
	ordered := make([]Entry, 0, len(found))
	for _, name := range stored {
		if e, ok := found[name]; ok && !seen[name] {
			seen[name] = true
			ordered = append(ordered, e)
		}
	}
	for _, e := range fresh {
		if !seen[e.Name] {
			seen[e.Name] = true
			ordered = append(ordered, e)
		}
	}

	return m.commit(ordered)
}

// SetOrder setzt die Reihenfolge neu. Nicht genannte (aber vorhandene) Dateien
// werden hinten angehaengt, damit nie etwas aus der Playliste verschwindet.
func (m *Manager) SetOrder(names []string) error {
	m.mu.RLock()
	current := append([]Entry(nil), m.entries...)
	m.mu.RUnlock()

	byName := make(map[string]Entry, len(current))
	for _, e := range current {
		byName[e.Name] = e
	}

	seen := make(map[string]bool, len(names))
	ordered := make([]Entry, 0, len(current))
	for _, n := range names {
		if e, ok := byName[n]; ok && !seen[n] {
			seen[n] = true
			ordered = append(ordered, e)
		}
	}
	for _, e := range current {
		if !seen[e.Name] {
			ordered = append(ordered, e)
		}
	}

	_, err := m.commit(ordered)
	return err
}

// SetEnabled blendet einen Eintrag auf der Wand ein oder aus. Die Datei bleibt
// dabei liegen und im Webinterface sichtbar.
func (m *Manager) SetEnabled(name string, enabled bool) error {
	safe, err := SafeName(name)
	if err != nil {
		return err
	}
	ausgeblendet := m.readDisabled()
	if enabled {
		delete(ausgeblendet, safe)
	} else {
		ausgeblendet[safe] = true
	}
	namen := make([]string, 0, len(ausgeblendet))
	for n := range ausgeblendet {
		namen = append(namen, n)
	}
	sort.Strings(namen)
	data, err := json.MarshalIndent(namen, "", "  ")
	if err != nil {
		return err
	}
	if err := config.WriteFileAtomic(m.disabledFile, append(data, '\n'), 0o664); err != nil {
		return fmt.Errorf("ausgeblendete eintraege speichern: %w", err)
	}
	_, err = m.Scan()
	return err
}

func (m *Manager) readDisabled() map[string]bool {
	out := map[string]bool{}
	data, err := os.ReadFile(m.disabledFile)
	if err != nil {
		return out
	}
	var namen []string
	if json.Unmarshal(data, &namen) != nil {
		return out
	}
	for _, n := range namen {
		out[n] = true
	}
	return out
}

// ActiveCount liefert die Anzahl der Eintraege, die tatsaechlich gezeigt werden.
func (m *Manager) ActiveCount() int {
	m.mu.RLock()
	defer m.mu.RUnlock()
	n := 0
	for _, e := range m.entries {
		if e.Enabled {
			n++
		}
	}
	return n
}

// Delete entfernt eine Datei aus dem Medienordner und der Playliste.
func (m *Manager) Delete(name string) error {
	safe, err := SafeName(name)
	if err != nil {
		return err
	}
	if err := os.Remove(filepath.Join(m.mediaDir, safe)); err != nil && !os.IsNotExist(err) {
		return err
	}
	_, err = m.Scan()
	return err
}

// commit uebernimmt die neue Reihenfolge, schreibt order.json + playlist.m3u
// und meldet die Aenderung an den Player.
func (m *Manager) commit(ordered []Entry) (bool, error) {
	m.mu.Lock()
	if sameEntries(m.entries, ordered) {
		m.mu.Unlock()
		return false, nil
	}
	m.entries = ordered
	m.version++
	m.mu.Unlock()

	names := make([]string, len(ordered))
	var m3u strings.Builder
	m3u.WriteString("#EXTM3U\n")
	for i, e := range ordered {
		names[i] = e.Name
		if !e.Enabled {
			continue // bleibt in der Liste, wird aber nicht abgespielt
		}
		m3u.WriteString(filepath.Join(m.mediaDir, e.Name))
		m3u.WriteByte('\n')
	}

	data, err := json.MarshalIndent(names, "", "  ")
	if err != nil {
		return true, err
	}
	if err := config.WriteFileAtomic(m.orderFile, append(data, '\n'), 0o664); err != nil {
		return true, fmt.Errorf("order.json schreiben: %w", err)
	}
	if err := config.WriteFileAtomic(m.playlistFile, []byte(m3u.String()), 0o664); err != nil {
		return true, fmt.Errorf("playlist.m3u schreiben: %w", err)
	}

	select {
	case m.changed <- struct{}{}:
	default:
	}
	return true, nil
}

func (m *Manager) readOrder() []string {
	data, err := os.ReadFile(m.orderFile)
	if err != nil {
		return nil
	}
	var names []string
	if json.Unmarshal(data, &names) != nil {
		return nil
	}
	return names
}

func sameEntries(a, b []Entry) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i].Name != b[i].Name || a[i].MTime != b[i].MTime ||
			a[i].Size != b[i].Size || a[i].Enabled != b[i].Enabled {
			return false
		}
	}
	return true
}

// SafeName verhindert Pfad-Ausbrueche (../) und leere Namen.
func SafeName(name string) (string, error) {
	name = filepath.Base(strings.TrimSpace(name))
	if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
		return "", fmt.Errorf("ungueltiger Dateiname")
	}
	return name, nil
}

// CleanUploadName macht aus einem beliebigen Upload-Namen einen brauchbaren
// Dateinamen: ohne Pfad, ohne Steuerzeichen, nicht mit Punkt beginnend.
func CleanUploadName(name string) string {
	name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
	var b strings.Builder
	for _, r := range name {
		switch {
		case r < 0x20 || r == 0x7f:
			// Steuerzeichen verwerfen
		case strings.ContainsRune(`/\:*?"<>|`, r):
			b.WriteByte('_')
		default:
			b.WriteRune(r)
		}
	}
	out := strings.TrimSpace(b.String())
	out = strings.TrimLeft(out, ".")
	if len(out) > 180 {
		ext := filepath.Ext(out)
		out = out[:180-len(ext)] + ext
	}
	return out
}

// UniqueName haengt bei Namenskollisionen -1, -2, ... an.
func UniqueName(dir, name string) string {
	if _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) {
		return name
	}
	ext := filepath.Ext(name)
	base := strings.TrimSuffix(name, ext)
	for i := 1; i < 10000; i++ {
		cand := fmt.Sprintf("%s-%d%s", base, i, ext)
		if _, err := os.Stat(filepath.Join(dir, cand)); os.IsNotExist(err) {
			return cand
		}
	}
	return name
}
