// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package netmap

import (
	"cmp"
	"net/netip"
	"reflect"
	"slices"
	"sync"
	"time"

	"tailscale.com/tailcfg"
)

// NodeMutation is the common interface for types that describe
// the change of a node's state.
type NodeMutation interface {
	NodeIDBeingMutated() tailcfg.NodeID
	Apply(*tailcfg.Node)
}

type mutatingNodeID tailcfg.NodeID

func (m mutatingNodeID) NodeIDBeingMutated() tailcfg.NodeID { return tailcfg.NodeID(m) }

// NodeMutationDERPHome is a NodeMutation that says a node
// has changed its DERP home region.
type NodeMutationDERPHome struct {
	mutatingNodeID
	DERPRegion int
}

func (m NodeMutationDERPHome) Apply(n *tailcfg.Node) {
	n.HomeDERP = m.DERPRegion
}

// NodeMutationEndpoints is a NodeMutation that says a node's endpoints have changed.
type NodeMutationEndpoints struct {
	mutatingNodeID
	Endpoints []netip.AddrPort
}

func (m NodeMutationEndpoints) Apply(n *tailcfg.Node) {
	n.Endpoints = slices.Clone(m.Endpoints)
}

// NodeMutationOnline is a NodeMutation that says a node is now online or
// offline.
type NodeMutationOnline struct {
	mutatingNodeID
	Online bool
}

func (m NodeMutationOnline) Apply(n *tailcfg.Node) {
	n.Online = new(m.Online)
}

// NodeMutationLastSeen is a NodeMutation that says a node's LastSeen
// value should be set to the current time.
type NodeMutationLastSeen struct {
	mutatingNodeID
	LastSeen time.Time
}

func (m NodeMutationLastSeen) Apply(n *tailcfg.Node) {
	n.LastSeen = new(m.LastSeen)
}

// NodeMutationUpsert is a NodeMutation that says a peer's full Node value
// should be inserted or replaced.
//
// Apply is a no-op: consumers of NodeMutationUpsert must type-switch to handle
// upserts by storing Node in their peer map.
type NodeMutationUpsert struct {
	Node tailcfg.NodeView
}

func (m NodeMutationUpsert) NodeIDBeingMutated() tailcfg.NodeID { return m.Node.ID() }
func (m NodeMutationUpsert) Apply(*tailcfg.Node)                {}

// NodeMutationRemove is a NodeMutation that says a peer has been removed.
// Apply is a no-op: consumers of NodeMutationRemove must type-switch to handle
// removes by deleting the node from their peer map.
type NodeMutationRemove struct {
	mutatingNodeID
}

func (m NodeMutationRemove) Apply(*tailcfg.Node) {}

// MakeNodeMutationRemove returns a NodeMutationRemove that targets nodeID.
// It is the constructor for NodeMutationRemove for callers outside this
// package, since the embedded ID field is unexported.
func MakeNodeMutationRemove(nodeID tailcfg.NodeID) NodeMutationRemove {
	return NodeMutationRemove{mutatingNodeID(nodeID)}
}

var peerChangeFields = sync.OnceValue(func() []reflect.StructField {
	var fields []reflect.StructField
	rt := reflect.TypeFor[tailcfg.PeerChange]()
	for field := range rt.Fields() {
		fields = append(fields, field)
	}
	return fields
})

// NodeMutationsFromPatch returns the NodeMutations that
// p describes. If p describes something not yet supported
// by a specific NodeMutation type, it returns (nil, false).
func NodeMutationsFromPatch(p *tailcfg.PeerChange) (_ []NodeMutation, ok bool) {
	if p == nil || p.NodeID == 0 {
		return nil, false
	}
	var ret []NodeMutation
	rv := reflect.ValueOf(p).Elem()
	for i, sf := range peerChangeFields() {
		if rv.Field(i).IsZero() {
			continue
		}
		switch sf.Name {
		default:
			// Unhandled field.
			return nil, false
		case "NodeID":
			continue
		case "DERPRegion":
			ret = append(ret, NodeMutationDERPHome{mutatingNodeID(p.NodeID), p.DERPRegion})
		case "Endpoints":
			ret = append(ret, NodeMutationEndpoints{mutatingNodeID(p.NodeID), slices.Clone(p.Endpoints)})
		case "Online":
			ret = append(ret, NodeMutationOnline{mutatingNodeID(p.NodeID), *p.Online})
		case "LastSeen":
			ret = append(ret, NodeMutationLastSeen{mutatingNodeID(p.NodeID), *p.LastSeen})
		}
	}
	return ret, true
}

// MutationsFromMapResponse returns all the discrete node mutations described
// by res. It returns ok=false if res contains any non-delta field as defined
// by mapResponseContainsNonPatchFields.
//
// Upserts and removes (from res.PeersChanged / res.PeersRemoved) are emitted
// as NodeMutationUpsert / NodeMutationRemove entries. A PeersChanged entry can
// be either a new peer or a full replacement for an existing peer that couldn't
// be represented as PeerChangedPatch. Callers must type-switch to handle those
// alongside field mutations.
func MutationsFromMapResponse(res *tailcfg.MapResponse, now time.Time) (ret []NodeMutation, ok bool) {
	if now.IsZero() {
		now = time.Now()
	}
	if mapResponseContainsNonPatchFields(res) {
		return nil, false
	}

	for _, id := range res.PeersRemoved {
		ret = append(ret, NodeMutationRemove{mutatingNodeID(id)})
	}
	for _, n := range res.PeersChanged {
		// Any n still in PeersChanged after patchifyPeersChanged is a
		// truly-new (or replaced) peer.
		ret = append(ret, NodeMutationUpsert{Node: n.View()})
	}
	for _, p := range res.PeersChangedPatch {
		deltas, ok := NodeMutationsFromPatch(p)
		if !ok {
			return nil, false
		}
		ret = append(ret, deltas...)
	}
	for nid, v := range res.OnlineChange {
		ret = append(ret, NodeMutationOnline{mutatingNodeID(nid), v})
	}
	for nid, v := range res.PeerSeenChange {
		if v {
			ret = append(ret, NodeMutationLastSeen{mutatingNodeID(nid), now})
		}
	}
	slices.SortStableFunc(ret, func(a, b NodeMutation) int {
		return cmp.Compare(a.NodeIDBeingMutated(), b.NodeIDBeingMutated())
	})
	return ret, true
}

// mapResponseContainsNonPatchFields reports whether res contains any field
// that can't be expressed as a per-peer NodeMutation (including the new
// NodeMutationUpsert / NodeMutationRemove variants) or via the sibling narrow
// setter methods on the map-session backend (e.g. UpdatePacketFilter).
//
// When this returns true, the caller must fall back to rebuilding and
// dispatching a full NetworkMap. When it returns false, the response can be
// handled incrementally.
//
// PeersChanged, PeersRemoved, and PacketFilter(s) are intentionally not in
// this list: upserted/removed peers ride NodeMutationUpsert/Remove, packet
// filter updates are delivered via the backend's UpdatePacketFilter
// method, and UserProfile updates ride the backend's UpdateUserProfiles
// method.
func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
	return res.Node != nil ||
		res.DERPMap != nil ||
		res.DNSConfig != nil ||
		res.Domain != "" ||
		res.CollectServices != "" ||
		res.Health != nil ||
		res.DisplayMessages != nil ||
		res.SSHPolicy != nil ||
		res.TKAInfo != nil ||
		res.DomainDataPlaneAuditLogID != "" ||
		res.Debug != nil ||
		res.ControlDialPlan != nil ||
		res.ClientVersion != nil ||
		res.Peers != nil ||
		res.DeprecatedDefaultAutoUpdate != ""
}
