package routes

import (
	"fmt"
	"net/netip"
	"slices"
	"sort"
	"strings"
	"sync"

	"github.com/juanfont/headscale/hscontrol/types"
	"github.com/juanfont/headscale/hscontrol/util"
	"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
	"github.com/rs/zerolog/log"
	xmaps "golang.org/x/exp/maps"
	"tailscale.com/net/tsaddr"
	"tailscale.com/util/set"
)

type PrimaryRoutes struct {
	mu sync.Mutex

	// routes is a map of prefixes that are adverties and approved and available
	// in the global headscale state.
	routes map[types.NodeID]set.Set[netip.Prefix]

	// primaries is a map of prefixes to the node that is the primary for that prefix.
	primaries map[netip.Prefix]types.NodeID
	isPrimary map[types.NodeID]bool
}

func New() *PrimaryRoutes {
	return &PrimaryRoutes{
		routes:    make(map[types.NodeID]set.Set[netip.Prefix]),
		primaries: make(map[netip.Prefix]types.NodeID),
		isPrimary: make(map[types.NodeID]bool),
	}
}

// updatePrimaryLocked recalculates the primary routes and updates the internal state.
// It returns true if the primary routes have changed.
// It is assumed that the caller holds the lock.
// The algorithm is as follows:
// 1. Reset the primaries map.
// 2. Iterate over the routes and count the number of times a prefix is advertised.
// 3. If a prefix is advertised by at least two nodes, it is a primary route.
// 4. If the primary routes have changed, update the internal state and return true.
// 5. Otherwise, return false.
func (pr *PrimaryRoutes) updatePrimaryLocked() bool {
	log.Debug().Caller().Msg("updatePrimaryLocked starting")

	// reset the primaries map, as we are going to recalculate it.
	allPrimaries := make(map[netip.Prefix][]types.NodeID)
	pr.isPrimary = make(map[types.NodeID]bool)
	changed := false

	// sort the node ids so we can iterate over them in a deterministic order.
	// this is important so the same node is chosen two times in a row
	// as the primary route.
	ids := types.NodeIDs(xmaps.Keys(pr.routes))
	sort.Sort(ids)

	// Create a map of prefixes to nodes that serve them so we
	// can determine the primary route for each prefix.
	for _, id := range ids {
		routes := pr.routes[id]
		for route := range routes {
			if _, ok := allPrimaries[route]; !ok {
				allPrimaries[route] = []types.NodeID{id}
			} else {
				allPrimaries[route] = append(allPrimaries[route], id)
			}
		}
	}

	// Go through all prefixes and determine the primary route for each.
	// If the number of routes is below the minimum, remove the primary.
	// If the current primary is still available, continue.
	// If the current primary is not available, select a new one.
	for prefix, nodes := range allPrimaries {
		log.Debug().
			Caller().
			Str(zf.Prefix, prefix.String()).
			Uints64("availableNodes", func() []uint64 {
				ids := make([]uint64, len(nodes))
				for i, id := range nodes {
					ids[i] = id.Uint64()
				}

				return ids
			}()).
			Msg("processing prefix for primary route selection")

		if node, ok := pr.primaries[prefix]; ok {
			// If the current primary is still available, continue.
			if slices.Contains(nodes, node) {
				log.Debug().
					Caller().
					Str(zf.Prefix, prefix.String()).
					Uint64("currentPrimary", node.Uint64()).
					Msg("current primary still available, keeping it")

				continue
			} else {
				log.Debug().
					Caller().
					Str(zf.Prefix, prefix.String()).
					Uint64("oldPrimary", node.Uint64()).
					Msg("current primary no longer available")
			}
		}

		if len(nodes) >= 1 {
			pr.primaries[prefix] = nodes[0]
			changed = true

			log.Debug().
				Caller().
				Str(zf.Prefix, prefix.String()).
				Uint64("newPrimary", nodes[0].Uint64()).
				Msg("selected new primary for prefix")
		}
	}

	// Clean up any remaining primaries that are no longer valid.
	for prefix := range pr.primaries {
		if _, ok := allPrimaries[prefix]; !ok {
			log.Debug().
				Caller().
				Str(zf.Prefix, prefix.String()).
				Msg("cleaning up primary route that no longer has available nodes")
			delete(pr.primaries, prefix)

			changed = true
		}
	}

	// Populate the quick lookup index for primary routes
	for _, nodeID := range pr.primaries {
		pr.isPrimary[nodeID] = true
	}

	log.Debug().
		Caller().
		Bool(zf.Changes, changed).
		Str(zf.FinalState, pr.stringLocked()).
		Msg("updatePrimaryLocked completed")

	return changed
}

// SetRoutes sets the routes for a given Node ID and recalculates the primary routes
// of the headscale.
// It returns true if there was a change in primary routes.
// All exit routes are ignored as they are not used in primary route context.
func (pr *PrimaryRoutes) SetRoutes(node types.NodeID, prefixes ...netip.Prefix) bool {
	pr.mu.Lock()
	defer pr.mu.Unlock()

	nlog := log.With().Uint64(zf.NodeID, node.Uint64()).Logger()

	nlog.Debug().
		Caller().
		Strs("prefixes", util.PrefixesToString(prefixes)).
		Msg("PrimaryRoutes.SetRoutes called")

	// If no routes are being set, remove the node from the routes map.
	if len(prefixes) == 0 {
		wasPresent := false

		if _, ok := pr.routes[node]; ok {
			delete(pr.routes, node)

			wasPresent = true

			nlog.Debug().
				Caller().
				Msg("removed node from primary routes (no prefixes)")
		}

		changed := pr.updatePrimaryLocked()
		nlog.Debug().
			Caller().
			Bool("wasPresent", wasPresent).
			Bool(zf.Changes, changed).
			Str(zf.NewState, pr.stringLocked()).
			Msg("SetRoutes completed (remove)")

		return changed
	}

	rs := make(set.Set[netip.Prefix], len(prefixes))
	for _, prefix := range prefixes {
		if !tsaddr.IsExitRoute(prefix) {
			rs.Add(prefix)
		}
	}

	if rs.Len() != 0 {
		pr.routes[node] = rs
		nlog.Debug().
			Caller().
			Strs("routes", util.PrefixesToString(rs.Slice())).
			Msg("updated node routes in primary route manager")
	} else {
		delete(pr.routes, node)
		nlog.Debug().
			Caller().
			Msg("removed node from primary routes (only exit routes)")
	}

	changed := pr.updatePrimaryLocked()
	nlog.Debug().
		Caller().
		Bool(zf.Changes, changed).
		Str(zf.NewState, pr.stringLocked()).
		Msg("SetRoutes completed (update)")

	return changed
}

func (pr *PrimaryRoutes) PrimaryRoutes(id types.NodeID) []netip.Prefix {
	if pr == nil {
		return nil
	}

	pr.mu.Lock()
	defer pr.mu.Unlock()

	// Short circuit if the node is not a primary for any route.
	if _, ok := pr.isPrimary[id]; !ok {
		return nil
	}

	var routes []netip.Prefix

	for prefix, node := range pr.primaries {
		if node == id {
			routes = append(routes, prefix)
		}
	}

	tsaddr.SortPrefixes(routes)

	return routes
}

func (pr *PrimaryRoutes) String() string {
	pr.mu.Lock()
	defer pr.mu.Unlock()

	return pr.stringLocked()
}

func (pr *PrimaryRoutes) stringLocked() string {
	var sb strings.Builder

	fmt.Fprintln(&sb, "Available routes:")

	ids := types.NodeIDs(xmaps.Keys(pr.routes))
	sort.Sort(ids)

	for _, id := range ids {
		prefixes := pr.routes[id]
		fmt.Fprintf(&sb, "\nNode %d: %s", id, strings.Join(util.PrefixesToString(prefixes.Slice()), ", "))
	}

	fmt.Fprintln(&sb, "\n\nCurrent primary routes:")

	for route, nodeID := range pr.primaries {
		fmt.Fprintf(&sb, "\nRoute %s: %d", route, nodeID)
	}

	return sb.String()
}

// DebugRoutes represents the primary routes state in a structured format for JSON serialization.
type DebugRoutes struct {
	// AvailableRoutes maps node IDs to their advertised routes
	// In the context of primary routes, this represents the routes that are available
	// for each node. A route will only be available if it is advertised by the node
	// AND approved.
	// Only routes by nodes currently connected to the headscale server are included.
	AvailableRoutes map[types.NodeID][]netip.Prefix `json:"available_routes"`

	// PrimaryRoutes maps route prefixes to the primary node serving them
	PrimaryRoutes map[string]types.NodeID `json:"primary_routes"`
}

// DebugJSON returns a structured representation of the primary routes state suitable for JSON serialization.
func (pr *PrimaryRoutes) DebugJSON() DebugRoutes {
	pr.mu.Lock()
	defer pr.mu.Unlock()

	debug := DebugRoutes{
		AvailableRoutes: make(map[types.NodeID][]netip.Prefix),
		PrimaryRoutes:   make(map[string]types.NodeID),
	}

	// Populate available routes
	for nodeID, routes := range pr.routes {
		prefixes := routes.Slice()
		tsaddr.SortPrefixes(prefixes)
		debug.AvailableRoutes[nodeID] = prefixes
	}

	// Populate primary routes
	for prefix, nodeID := range pr.primaries {
		debug.PrimaryRoutes[prefix.String()] = nodeID
	}

	return debug
}
