package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	tea "charm.land/bubbletea/v2"

	"cliamp/applog"
	"cliamp/config"
	"cliamp/external/emby"
	"cliamp/external/jellyfin"
	"cliamp/external/local"
	"cliamp/external/navidrome"
	"cliamp/external/netease"
	"cliamp/external/plex"
	"cliamp/external/radio"
	"cliamp/external/radiometa"
	"cliamp/external/soundcloud"
	"cliamp/external/spotify"
	"cliamp/external/ytmusic"
	"cliamp/internal/appdir"
	"cliamp/internal/appmeta"
	"cliamp/internal/playback"
	"cliamp/internal/resume"
	"cliamp/ipc"
	"cliamp/luaplugin"
	"cliamp/mediactl"
	"cliamp/player"
	"cliamp/playlist"
	"cliamp/resolve"
	"cliamp/theme"
	"cliamp/ui"
	"cliamp/ui/model"
)

// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version string

const (
	defaultUIFPS  = 20
	lowPowerUIFPS = 5
)

func run(overrides config.Overrides, positional []string, daemon bool) error {
	cfg, err := config.Load()
	if err != nil {
		return fmt.Errorf("config: %w", err)
	}
	overrides.Apply(&cfg)

	closeLog, appliedLevel, logErr := initLogging(cfg.LogLevel)
	defer closeLog()
	if logErr != nil {
		fmt.Fprintf(os.Stderr, "logging: %v (continuing without file log)\n", logErr)
		applog.Status("logging: %v", logErr)
	} else {
		applog.Info("cliamp starting (version=%s level=%s)", appmeta.Version(), appliedLevel)
	}

	// Build provider list: Radio is always available, Navidrome and Spotify if configured.
	radioProv := radio.New()
	localProv := local.New()

	var providers []model.ProviderEntry
	providers = append(providers, model.ProviderEntry{Key: "radio", Name: "Radio", Provider: radioProv})
	if localProv != nil {
		providers = append(providers, model.ProviderEntry{Key: "local", Name: "Local", Provider: localProv})
	}

	var navClient *navidrome.NavidromeClient
	if c := navidrome.NewFromConfig(cfg.Navidrome); c != nil {
		navClient = c
	} else if c := navidrome.NewFromEnv(); c != nil {
		navClient = c
	}
	if navClient != nil {
		providers = append(providers, model.ProviderEntry{Key: "navidrome", Name: "Navidrome", Provider: navClient})
	}

	if plexProv := plex.NewFromConfig(cfg.Plex); plexProv != nil {
		providers = append(providers, model.ProviderEntry{Key: "plex", Name: "Plex", Provider: plexProv})
	}

	if jellyProv := jellyfin.NewFromConfig(cfg.Jellyfin); jellyProv != nil {
		providers = append(providers, model.ProviderEntry{Key: "jellyfin", Name: "Jellyfin", Provider: jellyProv})
	}

	if embyProv := emby.NewFromConfig(cfg.Emby); embyProv != nil {
		providers = append(providers, model.ProviderEntry{Key: "emby", Name: "Emby", Provider: embyProv})
	}

	var spotifyProv *spotify.SpotifyProvider
	if cfg.Spotify.IsSet() {
		clientID := cfg.Spotify.ResolveClientID(spotify.DefaultClientID)
		spotifyProv = spotify.New(nil, clientID, cfg.Spotify.Bitrate)
		providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
	}

	if scProv := soundcloud.NewFromConfig(soundcloud.Config{
		Enabled:     cfg.SoundCloud.Enabled,
		User:        cfg.SoundCloud.User,
		CookiesFrom: cfg.SoundCloud.CookiesFrom,
	}); scProv != nil {
		// Provider constructors configure resolve-side yt-dlp cookies. Mirror
		// cookies_from onto the player so streaming yt-dlp invocations use the
		// same browser session. Last write wins when multiple providers set it.
		if cfg.SoundCloud.CookiesFrom != "" {
			player.SetYTDLCookiesFrom(cfg.SoundCloud.CookiesFrom)
		}
		providers = append(providers, model.ProviderEntry{Key: "soundcloud", Name: "SoundCloud", Provider: scProv})
	}

	if neProv := netease.NewFromConfig(netease.Config{
		Enabled:     cfg.NetEase.Enabled,
		CookiesFrom: cfg.NetEase.CookiesFrom,
		UserID:      cfg.NetEase.UserID,
	}); neProv != nil {
		if cfg.NetEase.CookiesFrom != "" {
			player.SetYTDLCookiesFrom(cfg.NetEase.CookiesFrom)
		}
		providers = append(providers, model.ProviderEntry{Key: "netease", Name: "NetEase", Provider: neProv})
	}

	var ytProviders ytmusic.Providers
	ytWanted := cfg.YouTubeMusic.IsSetOrFallback(ytmusic.FallbackCredentials)
	if !ytWanted {
		switch cfg.Provider {
		case "yt", "youtube", "ytmusic":
			ytWanted = true
		}
	}
	if ytWanted {
		ytClientID, ytClientSecret := cfg.YouTubeMusic.ResolveCredentials(ytmusic.FallbackCredentials)
		if cfg.YouTubeMusic.CookiesFrom != "" {
			player.SetYTDLCookiesFrom(cfg.YouTubeMusic.CookiesFrom)
		}
		if ytClientID == "" || ytClientSecret == "" {
			fmt.Fprintf(os.Stderr, "YouTube: no credentials available (configure client_id/client_secret in config.toml)\n")
		} else {
			if !player.YTDLPAvailable() {
				fmt.Fprintf(os.Stderr, "\nYouTube requires yt-dlp for audio playback.\n")
				fmt.Fprintf(os.Stderr, "Install command: %s\n\n", player.YtdlpInstallHint())
				fmt.Fprintf(os.Stderr, "Press Enter to install automatically, or Ctrl+C to skip... ")
				fmt.Scanln()
				fmt.Fprintf(os.Stderr, "Installing yt-dlp...\n")
				if err := player.InstallYTDLP(); err != nil {
					fmt.Fprintf(os.Stderr, "Installation failed: %v\n", err)
					fmt.Fprintf(os.Stderr, "YouTube providers disabled. Install manually and restart.\n\n")
				} else {
					fmt.Fprintf(os.Stderr, "yt-dlp installed successfully!\n\n")
				}
			}
			if player.YTDLPAvailable() {
				ytProviders = ytmusic.New(nil, ytClientID, ytClientSecret, cfg.YouTubeMusic.CookiesFrom != "")
				providers = append(providers,
					model.ProviderEntry{Key: "yt", Name: "YouTube (All)", Provider: ytProviders.All},
					model.ProviderEntry{Key: "youtube", Name: "YouTube", Provider: ytProviders.Video},
					model.ProviderEntry{Key: "ytmusic", Name: "YouTube Music", Provider: ytProviders.Music},
				)
			}
		}
	}

	if spotifyProv != nil {
		defer spotifyProv.Close()
	}
	if ytProviders.Music != nil {
		defer ytProviders.Music.Close()
	}

	if len(positional) > 0 && (positional[0] == "search" || positional[0] == "search-sc") {
		if len(positional) == 1 {
			return fmt.Errorf("search requires a query string (e.g. cliamp search \"never gonna give you up\")")
		}
		prefix := "ytsearch1:"
		if positional[0] == "search-sc" {
			prefix = "scsearch1:"
		}
		query := strings.Join(positional[1:], " ")
		positional = []string{prefix + query}
	}

	resolved, err := resolve.Args(positional)
	if err != nil {
		return err
	}

	defaultProvider := cfg.Provider
	if defaultProvider == "" {
		defaultProvider = "radio"
	}

	defaultRadio := len(positional) == 0 && defaultProvider == "radio"

	pl := playlist.New()
	if cfg.Playlist != "" && localProv != nil {
		tracks, err := localProv.Tracks(cfg.Playlist)
		if err != nil {
			return fmt.Errorf("playlist %q: %w", cfg.Playlist, err)
		}
		pl.Add(tracks...)
	} else if defaultRadio {
		pl.Add(
			playlist.Track{Path: "http://radio.cliamp.stream/lofi/stream", Title: "Lofi Stream", Stream: true},
			playlist.Track{Path: "http://radio.cliamp.stream/synthwave/stream", Title: "Synthwave Stream", Stream: true},
			playlist.Track{Path: "http://radio.cliamp.stream/edm/stream", Title: "EDM Stream", Stream: true},
		)
	}
	pl.Add(resolved.Tracks...)

	// Daemon mode has no UI loop to drain pending URLs (feeds, M3U, yt-dlp),
	// so resolve them synchronously here. The TUI path does this in the
	// background via m.SetPendingURLs.
	if daemon && len(resolved.Pending) > 0 {
		fmt.Fprintf(os.Stderr, "cliamp: resolving %d remote URL(s)...\n", len(resolved.Pending))
		remote, err := resolve.Remote(resolved.Pending)
		if err != nil {
			return fmt.Errorf("resolve remote: %w", err)
		}
		pl.Add(remote...)
	}

	if cfg.AudioDevice != "" {
		cleanup := player.PrepareAudioDevice(cfg.AudioDevice)
		defer cleanup()
	}

	sampleRate := cfg.SampleRate
	if sampleRate == 0 {
		if detected := player.DeviceSampleRate(); detected > 0 {
			sampleRate = detected
		} else {
			sampleRate = 44100
		}
	}

	p, err := player.New(player.Quality{
		SampleRate:      sampleRate,
		BufferMs:        cfg.BufferMs,
		ResampleQuality: cfg.ResampleQuality,
		BitDepth:        cfg.BitDepth,
	})
	if err != nil {
		return fmt.Errorf("player: %w", err)
	}
	defer p.Close()

	if spotifyProv != nil {
		p.RegisterStreamerFactory("spotify:", spotifyProv.NewStreamer)
	}

	p.RegisterBufferedURLMatcher(func(u string) bool {
		return navidrome.IsSubsonicStreamURL(u) || jellyfin.IsStreamURL(u) || emby.IsStreamURL(u) || plex.IsStreamURL(u)
	})

	// Pull now-playing for stations that carry no inline ICY metadata (NTS, FIP).
	p.RegisterStreamMetadataResolver(radiometa.Resolver)

	cfg.ApplyPlayer(p)
	cfg.ApplyPlaylist(pl)
	ui.SetPadding(cfg.PaddingH, cfg.PaddingV)

	if daemon {
		return runDaemon(p, pl, localProv, cfg.AutoPlay)
	}

	themes := theme.LoadAll()

	luaMgr, luaErr := luaplugin.New(cfg.Plugins)
	if luaErr != nil {
		fmt.Fprintf(os.Stderr, "lua plugins: %v\n", luaErr)
	}
	if luaMgr != nil {
		defer luaMgr.Close()
		luaMgr.SetReservedKeys(model.ReservedKeys())
	}

	m := model.New(p, pl, providers, defaultProvider, localProv, themes, luaMgr, config.SaveFunc{})
	m.SetVisVolumeLinked(cfg.VisVolumeLinked)

	if luaMgr != nil {
		luaMgr.SetStateProvider(luaplugin.StateProvider{
			PlayerState: func() string {
				if !p.IsPlaying() {
					return "stopped"
				}
				if p.IsPaused() {
					return "paused"
				}
				return "playing"
			},
			Position:      func() float64 { return p.Position().Seconds() },
			Duration:      func() float64 { return p.Duration().Seconds() },
			Volume:        func() float64 { return p.Volume() },
			Speed:         func() float64 { return p.Speed() },
			Mono:          func() bool { return p.Mono() },
			RepeatMode:    func() string { return pl.Repeat().String() },
			Shuffle:       func() bool { return pl.Shuffled() },
			EQBands:       func() [10]float64 { return p.EQBands() },
			TrackTitle:    func() string { t, _ := pl.Current(); return t.Title },
			TrackArtist:   func() string { t, _ := pl.Current(); return t.Artist },
			TrackAlbum:    func() string { t, _ := pl.Current(); return t.Album },
			TrackGenre:    func() string { t, _ := pl.Current(); return t.Genre },
			TrackYear:     func() int { t, _ := pl.Current(); return t.Year },
			TrackNumber:   func() int { t, _ := pl.Current(); return t.TrackNumber },
			TrackPath:     func() string { t, _ := pl.Current(); return t.Path },
			TrackIsStream: func() bool { t, _ := pl.Current(); return t.Stream },
			TrackDuration: func() int { t, _ := pl.Current(); return t.DurationSecs },
			PlaylistCount: func() int { return pl.Len() },
			CurrentIndex:  func() int { return pl.Index() },
			QueueList: func() []luaplugin.QueueEntry {
				tracks := pl.Tracks()
				out := make([]luaplugin.QueueEntry, len(tracks))
				for i, t := range tracks {
					out[i] = luaplugin.QueueEntry{
						Title:  t.Title,
						Artist: t.Artist,
						Album:  t.Album,
						Path:   t.Path,
						Index:  i,
						Queued: pl.QueuePosition(i) >= 0,
					}
				}
				return out
			},
		})
	}

	if luaMgr != nil {
		if names := luaMgr.Visualizers(); len(names) > 0 {
			m.RegisterLuaVisualizers(names, luaMgr.RenderVis)
		}
	}

	m.SetSeekStepLarge(cfg.SeekStepLargeDuration())
	m.SetInitialDirectory(cfg.InitialDirectory)
	m.SetPendingURLs(resolved.Pending)
	if len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 && pl.Len() == 0 {
		m.StartInProvider()
	}
	if cfg.EQPreset != "" && cfg.EQPreset != "Custom" {
		m.SetEQPreset(cfg.EQPreset, nil)
	}
	if cfg.Theme != "" {
		m.SetTheme(cfg.Theme)
	}
	if cfg.Visualizer != "" {
		m.SetVisualizer(cfg.Visualizer)
	}
	if cfg.AutoPlay {
		m.SetAutoPlay(true)
	}
	if cfg.LowPower {
		m.SetLowPower(true)
	}
	if cfg.Compact {
		m.SetCompact(true)
	}

	if !defaultRadio && len(positional) > 0 {
		if rs := resume.Load(); rs.Path != "" && rs.PositionSec > 0 {
			m.SetResume(rs.Path, rs.PositionSec)
		}
	}

	progOpts := []tea.ProgramOption{tea.WithFPS(defaultUIFPS)}
	if cfg.LowPower {
		progOpts[0] = tea.WithFPS(lowPowerUIFPS)
	}
	prog := tea.NewProgram(m, progOpts...)

	if spotifyProv != nil {
		spotify.SetAuthURLObserver(func(u string) {
			prog.Send(model.ProvAuthURLMsg{URL: u})
		})
		defer spotify.SetAuthURLObserver(nil)
	}

	svc, svcErr := wireMediaCtl(prog)
	if svcErr != nil {
		applog.Warn("media control (MPRIS/NowPlaying) unavailable: %v", svcErr)
	} else if svc != nil {
		defer svc.Close()
	}

	if luaMgr != nil {
		luaMgr.SetControlProvider(luaplugin.ControlProvider{
			SetVolume:   func(db float64) { p.SetVolume(db) },
			SetSpeed:    func(ratio float64) { p.SetSpeed(ratio) },
			SetEQBand:   func(band int, db float64) { p.SetEQBand(band, db) },
			ToggleMono:  func() { p.ToggleMono() },
			TogglePause: func() { p.TogglePause() },
			Stop:        func() { p.Stop() },
			Seek: func(secs float64) {
				_ = p.Seek(time.Duration(secs * float64(time.Second)))
			},
			SetEQPreset: func(name string, bands *[10]float64) {
				prog.Send(model.SetEQPresetMsg{Name: name, Bands: bands})
			},
			Next: func() { prog.Send(playback.NextMsg{}) },
			Prev: func() { prog.Send(playback.PrevMsg{}) },
			QueueAdd: func(path string) {
				prog.Send(model.PluginQueueMsg{Op: "add", Path: path})
			},
			QueueJump: func(index int) {
				prog.Send(model.PluginQueueMsg{Op: "jump", Index: index})
			},
			QueueRemove: func(index int) {
				prog.Send(model.PluginQueueMsg{Op: "remove", Index: index})
			},
			QueueMove: func(from, to int) {
				prog.Send(model.PluginQueueMsg{Op: "move", Index: from, To: to})
			},
		})
		luaMgr.SetUIProvider(luaplugin.UIProvider{
			ShowMessage: func(text string, duration time.Duration) {
				prog.Send(model.ShowStatusMsg{Text: text, Duration: duration})
			},
		})
	}

	ipcSrv, ipcErr := ipc.NewServer(ipc.DefaultSocketPath(), ipc.DispatcherFunc(func(msg any) { prog.Send(msg) }))
	if ipcErr != nil {
		fmt.Fprintf(os.Stderr, "ipc: %v\n", ipcErr)
	} else {
		defer ipcSrv.Close()
		if luaMgr != nil {
			ipcSrv.SetPluginDispatcher(luaMgr)
		}
	}

	finalModel, err := mediactl.Run(prog, svc)
	if err != nil {
		return err
	}

	if fm, ok := finalModel.(model.Model); ok {
		themeName := fm.ThemeName()
		if themeName == theme.DefaultName {
			themeName = ""
		}
		_ = config.Save("theme", fmt.Sprintf("%q", themeName))

		if path, secs, pl := fm.ResumeState(); path != "" && secs > 0 {
			resume.Save(path, secs, pl)
		}
	}

	return nil
}

// initLogging always returns a non-nil close func so the caller can defer
// it unconditionally, plus the applied level as a string for diagnostics.
// Errors come back as the third return value; the close func is a no-op
// and the level string is empty in that case.
func initLogging(levelStr string) (func() error, string, error) {
	noop := func() error { return nil }
	level, err := applog.ParseLevel(levelStr)
	if err != nil {
		return noop, "", err
	}
	dir, err := appdir.Dir()
	if err != nil {
		return noop, "", fmt.Errorf("resolve config dir: %w", err)
	}
	closeFn, err := applog.Init(filepath.Join(dir, "cliamp.log"), level)
	if err != nil {
		return noop, "", err
	}
	return closeFn, level.String(), nil
}

func wireMediaCtl(prog *tea.Program) (*mediactl.Service, error) {
	svc, err := mediactl.New(prog.Send)
	if err != nil || svc == nil {
		return svc, err
	}
	go prog.Send(model.AttachNotifier(svc))
	return svc, nil
}

func ipcSend(req ipc.Request) (ipc.Response, error) {
	resp, err := ipc.Send(ipc.DefaultSocketPath(), req)
	if err != nil {
		return resp, err
	}
	if !resp.OK {
		return resp, fmt.Errorf("%s", resp.Error)
	}
	return resp, nil
}

// ipcSendLong is like ipcSend with a caller-chosen deadline, for plugin
// commands that can legitimately run for minutes (e.g. yt-dlp downloads).
func ipcSendLong(req ipc.Request, deadline time.Duration) (ipc.Response, error) {
	resp, err := ipc.SendWithDeadline(ipc.DefaultSocketPath(), req, deadline)
	if err != nil {
		return resp, err
	}
	if !resp.OK {
		return resp, fmt.Errorf("%s", resp.Error)
	}
	return resp, nil
}

func main() {
	appmeta.SetVersion(version)
	app := buildApp()
	if err := app.Run(context.Background(), os.Args); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}
