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

package ipnlocal

import (
	"context"
	"errors"
	"fmt"

	"tailscale.com/feature/buildfeatures"
	"tailscale.com/ipn/ipnlocal/netmapcache"
	"tailscale.com/types/netmap"
)

// diskCache is the state netmap caching to disk.
type diskCache struct {
	// all fields guarded by LocalBackend.mu

	dir   string // active profile cache directory
	cache *netmapcache.Cache
}

// writeNetmapToDiskLockedWithoutPeers updates nm in the cache, excluding peers and profiles.
func (b *LocalBackend) writeNetmapToDiskLockedWithoutPeers(nm *netmap.NetworkMap) error {
	if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
		return nil
	} else if err := b.ensureDiskCacheLocked(); err != nil {
		return err
	}
	b.logf("updating netmap in disk cache")
	return b.diskCache.cache.UpdateSelfOnly(b.currentNode().Context(), b.patchNetmapHomeDERPLocked(nm))
}

// writeNetmapToDiskLockedWithPeers writes nm into the cache, including peers and profiles.
func (b *LocalBackend) writeNetmapToDiskLockedWithPeers(nm *netmap.NetworkMap) error {
	if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
		return nil
	} else if err := b.ensureDiskCacheLocked(); err != nil {
		return err
	}
	b.logf("writing netmap to disk cache")
	return b.diskCache.cache.Store(b.currentNode().Context(), b.patchNetmapHomeDERPLocked(nm))
}

func (b *LocalBackend) patchNetmapHomeDERPLocked(nm *netmap.NetworkMap) *netmap.NetworkMap {
	// Set the homeDERP on the self node before saving. The self node homeDERP is
	// generally not used since the homeDERP for self is stored in magicsock, but
	// to be able to load it during loading the cache, we use the existing field
	// to save it.

	// Make a shallow copy and mutate a copy of the selfNode.
	nmCopy := *nm
	selfNode := nm.SelfNode.AsStruct()
	selfNode.HomeDERP = int(b.currentNode().homeDERP.Load())
	nmCopy.SelfNode = selfNode.View()
	return &nmCopy
}

func (b *LocalBackend) ensureDiskCacheLocked() error {
	dir, err := b.profileMkdirAllLocked(b.pm.CurrentProfile().ID(), "netmap-cache")
	if err != nil {
		return err
	}
	if c := b.diskCache; c.cache == nil || c.dir != dir {
		b.diskCache.cache = netmapcache.NewCache(netmapcache.FileStore(dir))
		b.diskCache.dir = dir
	}
	return nil
}

func (b *LocalBackend) loadDiskCacheLocked() (om *netmap.NetworkMap, ok bool) {
	if !buildfeatures.HasCacheNetMap {
		return nil, false
	}
	dir, err := b.profileMkdirAllLocked(b.pm.CurrentProfile().ID(), "netmap-cache")
	if err != nil {
		b.logf("profile data directory: %v", err)
		return nil, false
	}
	if c := b.diskCache; c.cache == nil || c.dir != dir {
		b.diskCache.cache = netmapcache.NewCache(netmapcache.FileStore(dir))
		b.diskCache.dir = dir
	}
	nm, err := b.diskCache.cache.Load(b.currentNode().Context())
	if err != nil {
		b.logf("load netmap from cache: %v", err)
		return nil, false
	}
	return nm, true
}

// discardDiskCacheLocked removes a cached network map for the current node, if
// one exists, and disables the cache.
func (b *LocalBackend) discardDiskCacheLocked() {
	if !buildfeatures.HasCacheNetMap {
		return
	}
	if b.diskCache.cache == nil {
		return // nothing to do, we do not have a cache
	}
	// Reaching here, we have a cache directory that needs to be purged.
	// Log errors but do not fail for them.
	store := netmapcache.FileStore(b.diskCache.dir)
	if err := b.clearStoreLocked(b.currentNode().Context(), store); err != nil {
		b.logf("clearing netmap cache: %v", err)
	}
	b.diskCache = diskCache{} // drop in-memory state
}

// clearStoreLocked discards all the keys in the specified store.
func (b *LocalBackend) clearStoreLocked(ctx context.Context, store netmapcache.Store) error {
	var errs []error
	for key, err := range store.List(ctx, "") {
		if err != nil {
			errs = append(errs, fmt.Errorf("list cache contest: %w", err))
			break
		}
		if err := store.Remove(ctx, key); err != nil {
			errs = append(errs, fmt.Errorf("discard cache key %q: %w", key, err))
		}
	}
	return errors.Join(errs...)
}

// ClearNetmapCache discards stored netmap caches (if any) for profiles for the
// current user of b. It also drops any cache from the active backend session,
// if there is one.
func (b *LocalBackend) ClearNetmapCache(ctx context.Context) error {
	if !buildfeatures.HasCacheNetMap {
		return nil // disabled
	}

	b.mu.Lock()
	defer b.mu.Unlock()

	var errs []error
	for _, p := range b.pm.Profiles() {
		store := netmapcache.FileStore(b.profileDataPathLocked(p.ID(), "netmap-cache"))
		err := b.clearStoreLocked(ctx, store)
		if err != nil {
			errs = append(errs, fmt.Errorf("clear netmap cache for profile %q: %w", p.ID(), err))
		}
	}

	b.diskCache = diskCache{} // drop in-memory state
	return errors.Join(errs...)
}
