package discovery

import (
	"context"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"regexp"
	"strings"
	"sync"
	"time"

	"github.com/gesellix/bose-soundtouch/pkg/config"
	"github.com/gesellix/bose-soundtouch/pkg/models"
	"golang.org/x/net/ipv4"
)

// Service handles UPnP SSDP discovery of SoundTouch devices
type Service struct {
	timeout    time.Duration
	cache      map[string]*models.DiscoveredDevice
	cacheTTL   time.Duration
	mutex      sync.RWMutex
	config     *config.Config
	httpClient *http.Client
	ifaceName  string
}

// NewService creates a new UPnP discovery service
func NewService(timeout time.Duration) *Service {
	if timeout == 0 {
		timeout = defaultTimeout
	}

	return &Service{
		timeout:    timeout,
		cache:      make(map[string]*models.DiscoveredDevice),
		cacheTTL:   defaultCacheTTL,
		mutex:      sync.RWMutex{},
		config:     config.DefaultConfig(),
		httpClient: &http.Client{Timeout: 5 * time.Second},
	}
}

// NewServiceWithConfig creates a new discovery service with configuration
func NewServiceWithConfig(cfg *config.Config) *Service {
	timeout := cfg.DiscoveryTimeout
	if timeout == 0 {
		timeout = defaultTimeout
	}

	cacheTTL := cfg.CacheTTL
	if cacheTTL == 0 {
		cacheTTL = defaultCacheTTL
	}

	return &Service{
		timeout:    timeout,
		cache:      make(map[string]*models.DiscoveredDevice),
		cacheTTL:   cacheTTL,
		mutex:      sync.RWMutex{},
		config:     cfg,
		httpClient: &http.Client{Timeout: 5 * time.Second},
		ifaceName:  cfg.DiscoveryInterface,
	}
}

// DiscoverDevices discovers all SoundTouch devices on the network
func (d *Service) DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevice, error) {
	// Check cache first
	d.cleanupCache()

	cached := d.getCachedDevices()
	if len(cached) > 0 {
		return cached, nil
	}

	var allDevices []*models.DiscoveredDevice

	// Add configured devices first
	configuredDevices := d.getConfiguredDevices()
	allDevices = append(allDevices, configuredDevices...)

	// Perform UPnP discovery if enabled
	if d.config.UPnPEnabled {
		upnpDevices, err := d.PerformDiscovery(ctx)
		if err != nil {
			log.Printf("UPnP: Discovery failed: %v", err)
			// Don't fail completely if UPnP fails, just log and continue with configured devices
			// We'll just use configured devices
		} else {
			// Merge UPnP devices, avoiding duplicates
			allDevices = d.mergeDevices(allDevices, upnpDevices)
		}
	}

	// Update cache
	d.updateCache(allDevices)

	return allDevices, nil
}

// DiscoverDevice discovers a specific SoundTouch device by host
func (d *Service) DiscoverDevice(ctx context.Context, host string) (*models.DiscoveredDevice, error) {
	// Check cache first
	d.mutex.RLock()

	if device, exists := d.cache[host]; exists && time.Since(device.LastSeen) < d.cacheTTL {
		d.mutex.RUnlock()
		return device, nil
	}

	d.mutex.RUnlock()

	// Try to discover all devices and find the specific one
	devices, err := d.DiscoverDevices(ctx)
	if err != nil {
		return nil, err
	}

	for _, device := range devices {
		if device.Host == host {
			return device, nil
		}
	}

	return nil, fmt.Errorf("device with host %s not found", host)
}

// GetCachedDevices returns all cached devices that haven't expired
func (d *Service) GetCachedDevices() []*models.DiscoveredDevice {
	d.cleanupCache()
	return d.getCachedDevices()
}

// ClearCache clears the device cache
func (d *Service) ClearCache() {
	d.mutex.Lock()
	defer d.mutex.Unlock()

	d.cache = make(map[string]*models.DiscoveredDevice)
}

// PerformDiscovery performs the actual UPnP SSDP discovery
func (d *Service) PerformDiscovery(ctx context.Context) ([]*models.DiscoveredDevice, error) {
	log.Printf("UPnP: Starting SSDP discovery for '%s' with timeout %v", soundTouchURN, d.timeout)

	listener, err := d.setupUDPListener()
	if err != nil {
		return nil, err
	}

	defer func() {
		_ = listener.Close()
	}()

	multicastAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
	if err != nil {
		log.Printf("UPnP: Failed to resolve multicast address %s: %v", ssdpAddr, err)
		return nil, fmt.Errorf("failed to resolve multicast address: %w", err)
	}

	if err = d.sendMSearch(listener, multicastAddr); err != nil {
		return nil, err
	}

	// Listen for responses
	devices := make(map[string]*models.DiscoveredDevice)

	responseCount, err := d.listenForResponses(ctx, listener, devices)
	if err != nil {
		return nil, err
	}

	// Convert map to slice
	result := make([]*models.DiscoveredDevice, 0, len(devices))
	for _, device := range devices {
		result = append(result, device)
	}

	log.Printf("UPnP: Discovery completed. Processed %d responses, found %d unique devices", responseCount, len(result))

	for i, device := range result {
		log.Printf("UPnP: Device #%d: %s at %s:%d (UPnP Location: %s)", i+1, sanitizeLog(device.Name), sanitizeLog(device.Host), device.Port, sanitizeLog(device.UPnPLocation))
	}

	return result, nil
}

func (d *Service) setupUDPListener() (*net.UDPConn, error) {
	listenIP, iface, err := d.resolveListenInterface()
	if err != nil {
		return nil, err
	}

	listenAddr := &net.UDPAddr{IP: listenIP, Port: 0}

	listener, err := net.ListenUDP("udp4", listenAddr)
	if err != nil {
		log.Printf("UPnP: Failed to create UDP listener on %s: %v", listenAddr, err)
		return nil, fmt.Errorf("failed to create UDP listener: %w", err)
	}

	addr := listener.LocalAddr()

	localAddr, ok := addr.(*net.UDPAddr)
	if !ok {
		_ = listener.Close()

		log.Printf("UPnP: Failed to cast local address to UDPAddr: %v", addr)

		return nil, fmt.Errorf("failed to cast local address to UDPAddr: %v", addr)
	}

	// Pin the outgoing multicast packets to the configured interface so the
	// M-SEARCH leaves through the right NIC on multi-homed hosts.
	if iface != nil {
		if err := ipv4.NewPacketConn(listener).SetMulticastInterface(iface); err != nil {
			log.Printf("UPnP: Failed to set multicast interface to %q: %v", sanitizeLog(iface.Name), err)
			// Continue regardless — the kernel will fall back to its own routing decision.
		}
	}

	logVerbose("UPnP: Created UDP listener on %s", localAddr.String())

	return listener, nil
}

// resolveListenInterface returns the source IP to bind the UDP listener to and
// the interface to use for outgoing multicast. When no interface is configured,
// the IP is nil (wildcard) and the iface is nil, preserving the historical
// behaviour where the kernel picks a route.
func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
	if d.ifaceName == "" {
		return nil, nil, nil
	}

	iface, err := net.InterfaceByName(d.ifaceName)
	if err != nil {
		log.Printf("UPnP: Configured interface %q not found: %v", sanitizeLog(d.ifaceName), err)
		return nil, nil, fmt.Errorf("configured interface %q not found: %w", d.ifaceName, err)
	}

	addrs, err := iface.Addrs()
	if err != nil {
		log.Printf("UPnP: Failed to read addresses for interface %q: %v", sanitizeLog(d.ifaceName), err)
		return nil, nil, fmt.Errorf("read addresses for interface %q: %w", d.ifaceName, err)
	}

	for _, addr := range addrs {
		ipNet, ok := addr.(*net.IPNet)
		if !ok {
			continue
		}

		ipv4Addr := ipNet.IP.To4()
		if ipv4Addr == nil || ipNet.IP.IsLoopback() {
			continue
		}

		log.Printf("UPnP: Binding UDP listener to interface %q (%s)", sanitizeLog(iface.Name), sanitizeLog(ipv4Addr.String()))

		return ipv4Addr, iface, nil
	}

	log.Printf("UPnP: Configured interface %q has no usable IPv4 address", sanitizeLog(d.ifaceName))

	return nil, nil, fmt.Errorf("interface %q has no usable IPv4 address", d.ifaceName)
}

func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
	msearchRequest := d.buildMSearchRequest()
	logVerbose("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))

	bytesWritten, err := listener.WriteToUDP([]byte(msearchRequest), multicastAddr)
	if err != nil {
		log.Printf("UPnP: Failed to send M-SEARCH request: %v", err)
		return fmt.Errorf("failed to send M-SEARCH: %w", err)
	}

	logVerbose("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)

	return nil
}

func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn, devices map[string]*models.DiscoveredDevice) (int, error) {
	responseCount := 0

	// Set read deadline
	deadline := time.Now().Add(d.timeout)
	if err := listener.SetReadDeadline(deadline); err != nil {
		log.Printf("UPnP: Failed to set read deadline: %v", err)
		return 0, fmt.Errorf("failed to set read deadline: %w", err)
	}

	logVerbose("UPnP: Set read deadline to %v, now listening for responses...", deadline.Format("15:04:05.000"))

	buffer := make([]byte, 4096)

	for time.Now().Before(deadline) {
		select {
		case <-ctx.Done():
			logVerbose("UPnP: Discovery cancelled by context")
			return responseCount, ctx.Err()
		default:
			n, remoteAddr, err := listener.ReadFromUDP(buffer)
			if err != nil {
				var netErr net.Error
				if errors.As(err, &netErr) && netErr.Timeout() {
					logVerbose("UPnP: Read timeout reached after %v, stopping discovery", d.timeout)
					return responseCount, nil
				}

				log.Printf("UPnP: Error reading response: %v", err)

				return responseCount, fmt.Errorf("failed to read response: %w", err)
			}

			responseCount++
			responseText := string(buffer[:n])
			logVerbose("UPnP: Received response #%d (%d bytes) from %s:\n%s", responseCount, n, remoteAddr.String(), strings.TrimSpace(responseText))

			device, err := d.parseResponse(responseText)
			if err != nil {
				log.Printf("UPnP: Failed to parse response #%d from %s: %v", responseCount, sanitizeLog(remoteAddr.String()), err)
				continue // Skip invalid responses
			}

			if device != nil {
				logVerbose("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port)
				devices[device.Host] = device
			} else {
				logVerbose("UPnP: Response #%d from %s did not contain a valid SoundTouch device", responseCount, remoteAddr.String())
			}
		}
	}

	return responseCount, nil
}

// buildMSearchRequest builds the M-SEARCH request for SoundTouch devices
func (d *Service) buildMSearchRequest() string {
	mx := int(d.timeout.Seconds())
	if mx < 1 {
		mx = 1
	}

	if mx > 5 {
		mx = 5
	}

	return fmt.Sprintf(
		"M-SEARCH * HTTP/1.1\r\n"+
			"HOST: %s\r\n"+
			"MAN: \"ssdp:discover\"\r\n"+
			"ST: %s\r\n"+
			"MX: %d\r\n"+
			"\r\n",
		ssdpAddr,
		soundTouchURN,
		mx,
	)
}

// parseResponse parses UPnP SSDP response and extracts device information
func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, error) {
	logVerbose("UPnP: Parsing response (%d chars): %.100s...", len(response), strings.ReplaceAll(response, "\r\n", "\\r\\n"))

	// Try both \r\n and \n line endings
	var lines []string
	if strings.Contains(response, "\r\n") {
		lines = strings.Split(response, "\r\n")
	} else {
		lines = strings.Split(response, "\n")
	}

	// Check if it's a valid HTTP response
	if len(lines) < 1 || !strings.HasPrefix(lines[0], "HTTP/1.1 200") {
		log.Printf("UPnP: Invalid HTTP response, first line: '%s'", sanitizeLog(lines[0]))
		return nil, fmt.Errorf("invalid HTTP response")
	}

	logVerbose("UPnP: Valid HTTP response detected")

	headers := make(map[string]string)

	for _, line := range lines[1:] {
		line = strings.TrimSpace(line)
		if line == "" {
			break
		}

		parts := strings.SplitN(line, ":", 2)
		if len(parts) == 2 {
			key := strings.TrimSpace(strings.ToLower(parts[0]))
			value := strings.TrimSpace(parts[1])
			headers[key] = value
		}
	}

	logVerbose("UPnP: Parsed %d headers from response", len(headers))

	for key, value := range headers {
		logVerbose("UPnP: Header: %s = %s", key, value)
	}

	// Check if it's a SoundTouch device
	st, exists := headers["st"]
	if !exists {
		log.Printf("UPnP: No ST header found in response")
		return nil, fmt.Errorf("no ST header found")
	}

	logVerbose("UPnP: Found ST header: %s", st)

	// Accept both MediaRenderer and any device type for now - we'll validate it's a SoundTouch later
	if !strings.Contains(strings.ToLower(st), "mediarenderer") && !strings.Contains(strings.ToLower(st), "upnp:rootdevice") {
		log.Printf("UPnP: Device type '%s' is not a MediaRenderer, skipping", sanitizeLog(st))
		return nil, fmt.Errorf("not a MediaRenderer device")
	}

	logVerbose("UPnP: Device type '%s' is acceptable", st)

	location, exists := headers["location"]
	if !exists {
		log.Printf("UPnP: No Location header found in response")
		return nil, fmt.Errorf("no location header found")
	}

	logVerbose("UPnP: Found Location header: %s", location)

	// Extract device information from location URL
	device, err := d.parseLocationURL(location, headers["usn"])
	if err != nil {
		log.Printf("UPnP: Failed to parse location URL '%s': %v", sanitizeLog(location), err)
		return nil, fmt.Errorf("failed to parse location URL: %w", err)
	}

	logVerbose("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port)

	// Try to get more device info from the location URL. Crucially this
	// also lets us reject non-Bose UPnP MediaRenderers (LG TVs, Onkyo /
	// Yamaha receivers, Dreambox tuners, etc.) that responded to our
	// generic `ST: …MediaRenderer:1` M-SEARCH. See issues #269 / #359.
	if err := d.EnrichDeviceInfo(device, location); err != nil {
		logVerbose("UPnP: Could not enrich device info from location '%s': %v — accepting tentatively (will be re-verified by /info probe)", location, err)
	} else if !isBoseUPnPDevice(device) {
		log.Printf("UPnP: Rejecting non-Bose device: model=%q (manufacturer not Bose / model not SoundTouch)", sanitizeLog(device.ModelID))
		return nil, fmt.Errorf("non-Bose UPnP device: %s", device.ModelID)
	} else {
		logVerbose("UPnP: Successfully enriched device info for %s (model=%q)", device.Name, device.ModelID)
	}

	return device, nil
}

// isBoseUPnPDevice classifies an enriched UPnP device as Bose vs. not.
// Returns true when either the manufacturer string contains "bose" or
// the model name carries a SoundTouch-family marker. Case-insensitive.
//
// This is the discrimination point that keeps non-Bose UPnP
// MediaRenderers (LG TVs, Onkyo receivers, Dreambox tuners) from
// landing in the `default` account on the service side — they all
// reply to our generic MediaRenderer:1 M-SEARCH because that URN is
// not Bose-specific.
func isBoseUPnPDevice(device *models.DiscoveredDevice) bool {
	if device == nil {
		return false
	}

	manuf := strings.ToLower(device.Manufacturer)
	model := strings.ToLower(device.ModelID)

	if strings.Contains(manuf, "bose") {
		return true
	}

	if strings.Contains(model, "soundtouch") {
		return true
	}

	return false
}

// parseLocationURL extracts basic device info from the location URL
func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevice, error) {
	logVerbose("UPnP: Parsing location URL: %s", location)

	// Parse the URL to extract host and port
	re := regexp.MustCompile(`http://([^:]+):(\d+)`)
	matches := re.FindStringSubmatch(location)

	if len(matches) < 2 {
		log.Printf("UPnP: Location URL '%s' does not match expected format http://host:port", sanitizeLog(location))
		return nil, fmt.Errorf("invalid location URL format")
	}

	host := matches[1]
	port := 8090 // Default SoundTouch port
	logVerbose("UPnP: Extracted host='%s', using default port=%d", host, port)

	device := &models.DiscoveredDevice{
		Host:            host,
		Port:            port,
		LastSeen:        time.Now(),
		Name:            fmt.Sprintf("SoundTouch-%s", host), // Default name
		DiscoveryMethod: "SSDP/UPnP",
		APIBaseURL:      fmt.Sprintf("http://%s:%d/", host, port),
		InfoURL:         fmt.Sprintf("http://%s:%d/info", host, port),
		UPnPLocation:    location,
		UPnPUSN:         usn,
	}

	return device, nil
}

// EnrichDeviceInfo tries to get additional device information from the device description
func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location string) error {
	logVerbose("UPnP: Attempting to enrich device info by fetching %s", location)

	resp, err := d.httpClient.Get(location)
	if err != nil {
		log.Printf("UPnP: Failed to fetch device description from %s: %v", sanitizeLog(location), err)
		return err
	}

	defer func() {
		_ = resp.Body.Close()
	}()

	logVerbose("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("failed to read body: %w", err)
	}

	var upnpRoot struct {
		XMLName xml.Name `xml:"root"`
		Device  struct {
			FriendlyName string `xml:"friendlyName"`
			Manufacturer string `xml:"manufacturer"`
			ModelName    string `xml:"modelName"`
			SerialNumber string `xml:"serialNumber"`
		} `xml:"device"`
	}

	if err := xml.Unmarshal(data, &upnpRoot); err != nil {
		log.Printf("UPnP: Failed to parse device description from %s: %v", sanitizeLog(location), err)
		return err
	}

	if upnpRoot.Device.FriendlyName != "" {
		device.Name = upnpRoot.Device.FriendlyName
	}

	if upnpRoot.Device.Manufacturer != "" {
		device.Manufacturer = upnpRoot.Device.Manufacturer
	}

	if upnpRoot.Device.ModelName != "" {
		device.ModelID = upnpRoot.Device.ModelName
	}

	if upnpRoot.Device.SerialNumber != "" {
		device.UPnPSerial = upnpRoot.Device.SerialNumber
	}

	logVerbose("UPnP: Enriched device info: Name='%s', Manufacturer='%s', Model='%s', UPnPSerial='%s'",
		device.Name, device.Manufacturer, device.ModelID, device.UPnPSerial)

	return nil
}

// updateCache updates the device cache with discovered devices
func (d *Service) updateCache(devices []*models.DiscoveredDevice) {
	d.mutex.Lock()
	defer d.mutex.Unlock()

	for _, device := range devices {
		d.cache[device.Host] = device
	}
}

// getCachedDevices returns all valid cached devices (internal method)
func (d *Service) getCachedDevices() []*models.DiscoveredDevice {
	d.mutex.RLock()
	defer d.mutex.RUnlock()

	devices := make([]*models.DiscoveredDevice, 0, len(d.cache))
	for _, device := range d.cache {
		if time.Since(device.LastSeen) < d.cacheTTL {
			devices = append(devices, device)
		}
	}

	return devices
}

// cleanupCache removes expired devices from cache
func (d *Service) cleanupCache() {
	d.mutex.Lock()
	defer d.mutex.Unlock()

	for host, device := range d.cache {
		if time.Since(device.LastSeen) >= d.cacheTTL {
			delete(d.cache, host)
		}
	}
}

// getConfiguredDevices returns devices from configuration
func (d *Service) getConfiguredDevices() []*models.DiscoveredDevice {
	return d.config.GetPreferredDevicesAsDiscovered()
}

// mergeDevices merges two device lists, avoiding duplicates based on host
func (d *Service) mergeDevices(existing, newDevices []*models.DiscoveredDevice) []*models.DiscoveredDevice {
	hostSet := make(map[string]bool)
	result := make([]*models.DiscoveredDevice, 0, len(existing)+len(newDevices))

	// Add existing devices
	for _, device := range existing {
		if !hostSet[device.Host] {
			result = append(result, device)
			hostSet[device.Host] = true
		}
	}

	// Add new devices if not already present
	for _, device := range newDevices {
		if !hostSet[device.Host] {
			result = append(result, device)
			hostSet[device.Host] = true
		}
	}

	return result
}
