// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package nat

import (
	"net/netip"
	"sync"
	"time"

	"github.com/luxfi/log"
	"github.com/luxfi/utils"
)

const (
	mapTimeout        = 30 * time.Minute
	maxRefreshRetries = 3
)

// Router describes the functionality that a network device must support to be
// able to open ports to an external IP.
type Router interface {
	// True iff this router supports NAT
	SupportsNAT() bool
	// Map external port [extPort] to internal port [intPort] for [duration]
	MapPort(intPort, extPort uint16, desc string, duration time.Duration) error
	// Undo a port mapping
	UnmapPort(intPort, extPort uint16) error
	// Return our external IP
	ExternalIP() (netip.Addr, error)
}

// GetRouter returns a router on the current network.
func GetRouter() Router {
	if r := getUPnPRouter(); r != nil {
		return r
	}
	if r := getPMPRouter(); r != nil {
		return r
	}

	return NewNoRouter()
}

// Mapper attempts to open a set of ports on a router
type Mapper struct {
	log    log.Logger
	r      Router
	closer chan struct{}
	wg     sync.WaitGroup
}

// NewPortMapper returns an initialized mapper
func NewPortMapper(log log.Logger, r Router) *Mapper {
	return &Mapper{
		log:    log,
		r:      r,
		closer: make(chan struct{}),
	}
}

// Map external port [extPort] (exposed to the internet) to internal port [intPort] (where our process is listening)
// and set [ip]. Does this every [updateTime]. [ip] may be nil.
func (m *Mapper) Map(
	intPort uint16,
	extPort uint16,
	desc string,
	ip *utils.Atomic[netip.AddrPort],
	updateTime time.Duration,
) {
	if !m.r.SupportsNAT() {
		return
	}

	// we attempt a port map, and log an Error if it fails.
	err := m.retryMapPort(intPort, extPort, desc, mapTimeout)
	if err != nil {
		m.log.Error("NAT traversal failed",
			log.Reflect("externalPort", extPort),
			log.Reflect("internalPort", intPort),
			log.Reflect("error", err),
		)
	} else {
		m.log.Info("NAT traversal successful",
			log.Reflect("externalPort", extPort),
			log.Reflect("internalPort", intPort),
		)
	}

	m.wg.Add(1)
	go m.keepPortMapping(intPort, extPort, desc, ip, updateTime)
}

// Retry port map up to maxRefreshRetries with a 1 second delay
func (m *Mapper) retryMapPort(intPort, extPort uint16, desc string, timeout time.Duration) error {
	var err error
	for retryCnt := 0; retryCnt < maxRefreshRetries; retryCnt++ {
		err = m.r.MapPort(intPort, extPort, desc, timeout)
		if err == nil {
			return nil
		}

		// log a message, sleep a second and retry.
		m.log.Warn("renewing port mapping failed",
			log.Reflect("attempt", retryCnt+1),
			log.Reflect("externalPort", extPort),
			log.Reflect("internalPort", intPort),
			log.Reflect("error", err),
		)
		time.Sleep(1 * time.Second)
	}
	return err
}

// keepPortMapping runs in the background to keep a port mapped. It renews the mapping from [extPort]
// to [intPort]] every [updateTime]. Updates [ip] every [updateTime].
func (m *Mapper) keepPortMapping(
	intPort uint16,
	extPort uint16,
	desc string,
	ip *utils.Atomic[netip.AddrPort],
	updateTime time.Duration,
) {
	updateTimer := time.NewTimer(updateTime)

	defer func(extPort uint16) {
		updateTimer.Stop()

		m.log.Debug("unmapping port",
			log.Reflect("externalPort", extPort),
		)

		if err := m.r.UnmapPort(intPort, extPort); err != nil {
			m.log.Debug("error unmapping port",
				log.Reflect("externalPort", extPort),
				log.Reflect("internalPort", intPort),
				log.Reflect("error", err),
			)
		}

		m.wg.Done()
	}(extPort)

	for {
		select {
		case <-updateTimer.C:
			err := m.retryMapPort(intPort, extPort, desc, mapTimeout)
			if err != nil {
				m.log.Warn("renew NAT traversal failed",
					log.Reflect("externalPort", extPort),
					log.Reflect("internalPort", intPort),
					log.Reflect("error", err),
				)
			}
			m.updateIP(ip)
			updateTimer.Reset(updateTime)
		case <-m.closer:
			return
		}
	}
}

func (m *Mapper) updateIP(ip *utils.Atomic[netip.AddrPort]) {
	if ip == nil {
		return
	}
	newAddr, err := m.r.ExternalIP()
	if err != nil {
		m.log.Error("failed to get external IP",
			log.Reflect("error", err),
		)
		return
	}
	oldAddrPort := ip.Get()
	oldAddr := oldAddrPort.Addr()
	if newAddr != oldAddr {
		port := oldAddrPort.Port()
		ip.Set(netip.AddrPortFrom(newAddr, port))
		m.log.Info("external IP updated",
			log.Stringer("oldIP", oldAddr),
			log.Stringer("newIP", newAddr),
		)
	}
}

// UnmapAllPorts stops mapping all ports from this mapper and attempts to unmap
// them.
func (m *Mapper) UnmapAllPorts() {
	close(m.closer)
	m.wg.Wait()
	m.log.Info("Unmapped all ports")
}
