package soundtouchweb

import (
	"encoding/json"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gesellix/bose-soundtouch/pkg/client"
	"github.com/gesellix/bose-soundtouch/pkg/discovery"
	"github.com/gesellix/bose-soundtouch/pkg/models"
	"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
	"github.com/go-chi/chi/v5"
)

// libraryServer is the JSON DTO for a DLNA media server. The registered and
// ready fields reflect state on the specific speaker that was queried;
// HandleDiscoverLibraryServers leaves them false because it performs a
// LAN-wide sweep with no device context.
type libraryServer struct {
	UDN           string `json:"udn"`
	Name          string `json:"name"`
	Manufacturer  string `json:"manufacturer"`
	Model         string `json:"model"`
	CDSControlURL string `json:"cdsControlURL"`
	Registered    bool   `json:"registered"`
	Ready         bool   `json:"ready"`
}

// libraryEntry is the JSON DTO for a single item returned by the speaker's
// /navigate endpoint. IsDir is derived from the item type so the frontend
// can render directory entries differently without an extra string comparison.
type libraryEntry struct {
	Name          string `json:"name"`
	Type          string `json:"type"`
	Location      string `json:"location"`
	SourceAccount string `json:"sourceAccount"`
	Playable      bool   `json:"playable"`
	IsDir         bool   `json:"isDir"`
}

// libraryPage wraps a slice of libraryEntry as the Data payload.
type libraryPage struct {
	Entries    []libraryEntry `json:"entries"`
	TotalItems int            `json:"totalItems"`
}

// normalizeUDN strips the "uuid:" prefix that UPnP device descriptions include
// in the UDN field (e.g. "uuid:fa095ecc-e13e-40e7-8e6c-e0286d5bc000") so the
// result matches the bare UUID that a SoundTouch speaker uses as the
// STORED_MUSIC sourceAccount before the "/0" suffix is appended.
func normalizeUDN(s string) string {
	return strings.TrimPrefix(s, "uuid:")
}

// HandleDiscoverLibraryServers performs a LAN-wide SSDP sweep for DLNA media
// servers, plus a query to every paired speaker's own /listMediaServers, and
// returns the merged set as a JSON array. An optional ?timeout= query
// parameter (in seconds, integer) overrides the default 5-second SSDP budget.
// This handler is global (not device-scoped) and lives under
// /api/control/providers/library/servers.
//
// The two sources see different networks: our SSDP sweep runs from the
// AfterTouch service host, while each speaker's /listMediaServers reflects
// what that speaker sees on its own LAN segment. They can disagree when the
// service isn't co-located with the speaker (different subnet/VLAN), so a
// server invisible to one path may still be visible via the other.
func (app *WebApp) HandleDiscoverLibraryServers(w http.ResponseWriter, r *http.Request) {
	timeout := 5 * time.Second

	if raw := r.URL.Query().Get("timeout"); raw != "" {
		if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
			timeout = time.Duration(secs) * time.Second
		}
	}

	servers, err := discovery.DiscoverMediaServers(r.Context(), timeout)
	if err != nil {
		app.sendError(w, err.Error(), http.StatusInternalServerError)
		return
	}

	byUDN := make(map[string]libraryServer, len(servers))
	order := make([]string, 0, len(servers))

	for _, s := range servers {
		udn := normalizeUDN(s.UDN)
		byUDN[udn] = libraryServer{
			UDN:           udn,
			Name:          s.FriendlyName,
			Manufacturer:  s.Manufacturer,
			Model:         s.ModelName,
			CDSControlURL: s.CDSControlURL,
		}
		order = append(order, udn)
	}

	deviceFound := app.discoverDeviceMediaServers()
	for i := range deviceFound {
		found := &deviceFound[i]

		udn := normalizeUDN(found.ID)
		if _, exists := byUDN[udn]; exists {
			// Already found via SSDP, which carries CDSControlURL; keep that
			// entry since registration itself only needs the UDN and name.
			continue
		}

		byUDN[udn] = libraryServer{
			UDN:          udn,
			Name:         found.FriendlyName,
			Manufacturer: found.Manufacturer,
			Model:        found.ModelName,
		}
		order = append(order, udn)
	}

	out := make([]libraryServer, 0, len(order))
	for _, udn := range order {
		out = append(out, byUDN[udn])
	}

	w.Header().Set("Content-Type", "application/json")

	if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: out}); encErr != nil {
		http.Error(w, "Failed to encode response", http.StatusInternalServerError)
	}
}

// discoverDeviceMediaServers queries every paired speaker's own
// /listMediaServers endpoint concurrently and returns the union of what they
// report, deduplicated by UDN. A speaker that is offline, times out, or runs
// firmware without the endpoint is skipped silently: this is a best-effort
// second discovery path, and one unreachable speaker must not fail or delay
// the overall response.
func (app *WebApp) discoverDeviceMediaServers() []models.MediaServerInfo {
	devices := app.DeviceSnapshot()

	type result struct {
		servers []models.MediaServerInfo
	}

	results := make(chan result, len(devices))

	var wg sync.WaitGroup

	for _, entry := range devices {
		deviceClient := entry.Device.Client
		if deviceClient == nil {
			continue
		}

		wg.Add(1)

		go func(c *client.Client) {
			defer wg.Done()

			resp, err := c.ListMediaServers()
			if err != nil || resp == nil {
				results <- result{}
				return
			}

			results <- result{servers: resp.MediaServers}
		}(deviceClient)
	}

	go func() {
		wg.Wait()
		close(results)
	}()

	seen := make(map[string]bool)

	out := make([]models.MediaServerInfo, 0, len(devices))

	for r := range results {
		for i := range r.servers {
			s := &r.servers[i]

			udn := normalizeUDN(s.ID)
			if udn == "" || seen[udn] {
				continue
			}

			seen[udn] = true

			out = append(out, *s)
		}
	}

	return out
}

// HandleDeviceLibraryServers returns the STORED_MUSIC sources currently
// registered on a specific speaker. Each source corresponds to one DLNA
// server that has been paired with that device.
func (app *WebApp) HandleDeviceLibraryServers(w http.ResponseWriter, r *http.Request) {
	deviceID := chi.URLParam(r, "id")

	device, exists := app.GetDevice(deviceID)
	if !exists {
		app.sendError(w, "Device not found", http.StatusNotFound)
		return
	}

	if device.Client == nil {
		app.sendError(w, "Device client not available", http.StatusInternalServerError)
		return
	}

	sources, err := device.Client.GetSources()
	if err != nil {
		app.sendError(w, err.Error(), http.StatusInternalServerError)
		return
	}

	out := make([]libraryServer, 0)

	for _, si := range sources.SourceItem {
		if si.Source != "STORED_MUSIC" {
			continue
		}

		udn := strings.TrimSuffix(si.SourceAccount, "/0")
		out = append(out, libraryServer{
			UDN:        udn,
			Name:       si.DisplayName,
			Registered: true,
			Ready:      si.Status == "READY",
		})
	}

	w.Header().Set("Content-Type", "application/json")

	if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: out}); encErr != nil {
		http.Error(w, "Failed to encode response", http.StatusInternalServerError)
	}
}

// HandleAddLibraryServer registers a DLNA media server on a specific speaker
// using the speaker's setMusicServiceAccount endpoint. The request body must
// contain {udn, name}. The account sent to the speaker is "<udn>/0" as
// required by the STORED_MUSIC protocol. Error code 1024 from the speaker
// means the account is already registered and is treated as success.
//
// After a successful registration the handler fires a best-effort
// sourcesUpdated notification so the speaker re-fetches its account list and
// registers the new source without requiring a power-cycle. The notification
// outcome is reflected in the response field "refreshed" but never fails the
// request.
func (app *WebApp) HandleAddLibraryServer(w http.ResponseWriter, r *http.Request) {
	deviceID := chi.URLParam(r, "id")

	device, exists := app.GetDevice(deviceID)
	if !exists {
		app.sendError(w, "Device not found", http.StatusNotFound)
		return
	}

	if device.Client == nil {
		app.sendError(w, "Device client not available", http.StatusInternalServerError)
		return
	}

	var req struct {
		UDN  string `json:"udn"`
		Name string `json:"name"`
	}

	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		app.sendError(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	if req.UDN == "" {
		app.sendError(w, "udn is required", http.StatusBadRequest)
		return
	}

	account := normalizeUDN(req.UDN) + "/0"

	if err := device.Client.AddStoredMusicAccount(account, req.Name); err != nil {
		// Error code 1024 means the account is already registered on the speaker.
		// Treat it as success so callers can be idempotent.
		if !strings.Contains(err.Error(), "1024") {
			app.sendError(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}

	// Resolve the Bose device ID for the sourcesUpdated nudge. Prefer the
	// cached DeviceInfo (no extra round-trip); fall back to a live /info
	// fetch only if the cached value is absent or empty.
	boseDeviceID := ""
	if device.DeviceInfo != nil && device.DeviceInfo.DeviceID != "" {
		boseDeviceID = device.DeviceInfo.DeviceID
	} else {
		if info, infoErr := device.Client.GetDeviceInfo(); infoErr == nil && info != nil {
			boseDeviceID = info.DeviceID
		}
	}

	// Send the sourcesUpdated nudge best-effort: the registration already
	// succeeded, so an error here must never fail the request.
	refreshed := false

	if boseDeviceID != "" {
		if nudgeErr := device.Client.NotifySourcesUpdated(boseDeviceID); nudgeErr == nil {
			refreshed = true
		}
	}

	w.Header().Set("Content-Type", "application/json")

	if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
		Success: true,
		Data:    map[string]interface{}{"account": account, "refreshed": refreshed},
	}); encErr != nil {
		http.Error(w, "Failed to encode response", http.StatusInternalServerError)
	}
}

// HandleRemoveLibraryServer unregisters a DLNA media server from a specific
// speaker. The {account} URL parameter is the full account string (e.g.
// "uuid:1234.../0") and must be URL-encoded by the caller.
func (app *WebApp) HandleRemoveLibraryServer(w http.ResponseWriter, r *http.Request) {
	deviceID := chi.URLParam(r, "id")

	device, exists := app.GetDevice(deviceID)
	if !exists {
		app.sendError(w, "Device not found", http.StatusNotFound)
		return
	}

	if device.Client == nil {
		app.sendError(w, "Device client not available", http.StatusInternalServerError)
		return
	}

	rawAccount := chi.URLParam(r, "account")

	account, err := url.PathUnescape(rawAccount)
	if err != nil {
		account = rawAccount
	}

	if account == "" {
		app.sendError(w, "account is required", http.StatusBadRequest)
		return
	}

	if err := device.Client.RemoveStoredMusicAccount(account, ""); err != nil {
		app.sendError(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")

	if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true}); encErr != nil {
		http.Error(w, "Failed to encode response", http.StatusInternalServerError)
	}
}

// HandleLibraryBrowse browses STORED_MUSIC content via the speaker's own
// /navigate endpoint, which returns speaker-native location tokens. These
// tokens are what /select requires when playing a track; raw DLNA ContentID
// values are not accepted by the speaker.
//
// Query parameters:
//   - account  (required) the sourceAccount string, e.g. "uuid:.../0"
//   - location (optional) location token from a previous browse; empty means root
//   - type     (optional) type hint for the container item, defaults to "dir"
//   - start    (optional) 1-based start index, defaults to 1
//   - count    (optional) number of items to return, defaults to 200
func (app *WebApp) HandleLibraryBrowse(w http.ResponseWriter, r *http.Request) {
	deviceID := chi.URLParam(r, "id")

	device, exists := app.GetDevice(deviceID)
	if !exists {
		app.sendError(w, "Device not found", http.StatusNotFound)
		return
	}

	if device.Client == nil {
		app.sendError(w, "Device client not available", http.StatusInternalServerError)
		return
	}

	account := r.URL.Query().Get("account")
	if account == "" {
		app.sendError(w, "account is required", http.StatusBadRequest)
		return
	}

	location := r.URL.Query().Get("location")
	itemType := r.URL.Query().Get("type")

	start := 1

	if raw := r.URL.Query().Get("start"); raw != "" {
		if v, err := strconv.Atoi(raw); err == nil && v >= 1 {
			start = v
		}
	}

	count := 200

	if raw := r.URL.Query().Get("count"); raw != "" {
		if v, err := strconv.Atoi(raw); err == nil && v >= 1 {
			count = v
		}
	}

	var (
		resp   *models.NavigateResponse
		navErr error
	)

	if location == "" {
		resp, navErr = device.Client.Navigate("STORED_MUSIC", account, start, count)
	} else {
		if itemType == "" {
			itemType = "dir"
		}

		container := &models.ContentItem{
			Source:        "STORED_MUSIC",
			SourceAccount: account,
			Location:      location,
			Type:          itemType,
		}
		resp, navErr = device.Client.NavigateContainer("STORED_MUSIC", account, start, count, container)
	}

	if navErr != nil {
		app.sendError(w, navErr.Error(), http.StatusInternalServerError)
		return
	}

	entries := make([]libraryEntry, 0, len(resp.Items))

	for _, item := range resp.Items {
		loc := ""
		if item.ContentItem != nil {
			loc = item.ContentItem.Location
		}

		entries = append(entries, libraryEntry{
			Name:          item.GetDisplayName(),
			Type:          item.Type,
			Location:      loc,
			SourceAccount: account,
			Playable:      item.Playable == 1,
			IsDir:         item.Type == "dir",
		})
	}

	w.Header().Set("Content-Type", "application/json")

	if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
		Success: true,
		Data: libraryPage{
			Entries:    entries,
			TotalItems: resp.TotalItems,
		},
	}); encErr != nil {
		http.Error(w, "Failed to encode response", http.StatusInternalServerError)
	}
}

// HandlePlayLibrary plays a STORED_MUSIC track on a specific speaker using
// the speaker's /select endpoint. The request body must contain:
//   - account  (required) sourceAccount, e.g. "uuid:.../0"
//   - location (required) the speaker-native location token from /navigate
//   - type     (optional) content type, defaults to "track"
//   - name     (optional) display name logged with the playback request
func (app *WebApp) HandlePlayLibrary(w http.ResponseWriter, r *http.Request) {
	deviceID := chi.URLParam(r, "id")

	device, exists := app.GetDevice(deviceID)
	if !exists {
		app.sendError(w, "Device not found", http.StatusNotFound)
		return
	}

	if device.Client == nil {
		app.sendError(w, "Device client not available", http.StatusInternalServerError)
		return
	}

	var req struct {
		Account  string `json:"account"`
		Location string `json:"location"`
		Type     string `json:"type"`
		Name     string `json:"name"`
	}

	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		app.sendError(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	if req.Account == "" {
		app.sendError(w, "account is required", http.StatusBadRequest)
		return
	}

	if req.Location == "" {
		app.sendError(w, "location is required", http.StatusBadRequest)
		return
	}

	itemType := req.Type
	if itemType == "" {
		itemType = "track"
	}

	ci := &models.ContentItem{
		Source:        "STORED_MUSIC",
		SourceAccount: req.Account,
		Location:      req.Location,
		Type:          itemType,
		ItemName:      req.Name,
		IsPresetable:  true,
	}

	logPlaybackRequest("library", deviceID, ci.Source, ci.SourceAccount, ci.Location, ci.ItemName)

	if err := device.Client.SelectContentItem(ci); err != nil {
		app.sendError(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")

	if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
		Success: true,
		Data:    map[string]string{"message": "Playing " + req.Name},
	}); encErr != nil {
		http.Error(w, "Failed to encode response", http.StatusInternalServerError)
	}
}
