package captain

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/safing/portmaster/base/log"
	"github.com/safing/portmaster/service/intel"
	"github.com/safing/portmaster/service/mgr"
	"github.com/safing/portmaster/service/netenv"
	"github.com/safing/portmaster/service/profile/endpoints"
	"github.com/safing/portmaster/spn/access"
	"github.com/safing/portmaster/spn/docks"
	"github.com/safing/portmaster/spn/hub"
	"github.com/safing/portmaster/spn/navigator"
	"github.com/safing/portmaster/spn/terminal"
)

const stopCraneAfterBeingUnsuggestedFor = 6 * time.Hour

var (
	// ErrAllHomeHubsExcluded is returned when all available home hubs were excluded.
	ErrAllHomeHubsExcluded = errors.New("all home hubs are excluded")

	// ErrReInitSPNSuggested is returned when no home hub can be found, even without rules.
	ErrReInitSPNSuggested = errors.New("SPN re-init suggested")
)

func establishHomeHub(ctx *mgr.WorkerCtx) error {
	// Get own IP.
	locations, ok := netenv.GetInternetLocation()
	if !ok || len(locations.All) == 0 {
		return errors.New("failed to locate own device")
	}
	log.Debugf(
		"spn/captain: looking for new home hub near %s and %s",
		locations.BestV4(),
		locations.BestV6(),
	)

	// Get own entity.
	// Checking the entity against the entry policies is somewhat hit and miss
	// anyway, as the device location is an approximation.
	var myEntity *intel.Entity
	if dl := locations.BestV4(); dl != nil && dl.IP != nil {
		myEntity = (&intel.Entity{IP: dl.IP}).Init(0)
		myEntity.FetchData(ctx.Ctx())
	} else if dl := locations.BestV6(); dl != nil && dl.IP != nil {
		myEntity = (&intel.Entity{IP: dl.IP}).Init(0)
		myEntity.FetchData(ctx.Ctx())
	}

	// Get home hub policy for selecting the home hub.
	homePolicy, err := getHomeHubPolicy()
	if err != nil {
		return err
	}

	// Build navigation options for searching for a home hub.
	opts := &navigator.Options{
		Home: &navigator.HomeHubOptions{
			HubPolicies:        []endpoints.Endpoints{homePolicy},
			CheckHubPolicyWith: myEntity,
		},
	}

	// Add requirement to only use Safing nodes when not using community nodes.
	if !cfgOptionUseCommunityNodes() {
		opts.Home.RequireVerifiedOwners = NonCommunityVerifiedOwners
	}

	// Require a trusted home node when the routing profile requires less than two hops.
	routingProfile := navigator.GetRoutingProfile(cfgOptionRoutingAlgorithm())
	if routingProfile.MinHops < 2 {
		opts.Home.Regard = opts.Home.Regard.Add(navigator.StateTrusted)
	}

	// Find nearby hubs.
findCandidates:
	candidates, err := navigator.Main.FindNearestHubs(
		locations.BestV4().LocationOrNil(),
		locations.BestV6().LocationOrNil(),
		opts, navigator.HomeHub,
	)
	if err != nil {
		switch {
		case errors.Is(err, navigator.ErrEmptyMap):
			// bootstrap to the network!
			err := bootstrapWithUpdates()
			if err != nil {
				return err
			}
			goto findCandidates

		case errors.Is(err, navigator.ErrAllPinsDisregarded):
			if len(homePolicy) > 0 {
				return ErrAllHomeHubsExcluded
			}
			return ErrReInitSPNSuggested

		default:
			return fmt.Errorf("failed to find nearby hubs: %w", err)
		}
	}

	// Try connecting to a hub.
	var tries int
	var candidate *hub.Hub
	for tries, candidate = range candidates {
		err = connectToHomeHub(ctx, candidate)
		if err != nil {
			// Check if context is canceled.
			if ctx.IsDone() {
				return ctx.Ctx().Err()
			}
			// Check if the SPN protocol is stopping again.
			if errors.Is(err, terminal.ErrStopping) {
				return err
			}
			log.Warningf("spn/captain: failed to connect to %s as new home: %s", candidate, err)
		} else {
			log.Infof("spn/captain: established connection to %s as new home with %d failed tries", candidate, tries)
			return nil
		}
	}
	if err != nil {
		return fmt.Errorf("failed to connect to a new home hub - tried %d hubs: %w", tries+1, err)
	}
	return errors.New("no home hub candidates available")
}

func connectToHomeHub(wCtx *mgr.WorkerCtx, dst *hub.Hub) error {
	// Create new context with timeout.
	// The maximum timeout is a worst case safeguard.
	// Keep in mind that multiple IPs and protocols may be tried in all configurations.
	// Some servers will be (possibly on purpose) hard to reach.
	ctx, cancel := context.WithTimeout(wCtx.Ctx(), 5*time.Minute)
	defer cancel()

	// Invoke hooks (if any) before opening connection
	if dst.Info != nil {
		if err := module.HookSPNConnecting.Invoke(wCtx, *dst.Info); err != nil {
			return fmt.Errorf("pre-connect hook rejected: %w", err)
		}
	}

	// Set and clean up exceptions.
	setExceptions(dst.Info.IPv4, dst.Info.IPv6)
	defer setExceptions(nil, nil)

	// Connect to hub.
	crane, err := EstablishCrane(ctx, dst)
	if err != nil {
		return err
	}

	// Cleanup connection in case of failure.
	var success bool
	defer func() {
		if !success {
			crane.Stop(nil)
		}
	}()

	// Query all gossip msgs on first connection.
	gossipQuery, tErr := NewGossipQueryOp(crane.Controller)
	if tErr != nil {
		log.Warningf("spn/captain: failed to start initial gossip query: %s", tErr)
	}
	// Wait for gossip query to complete.
	select {
	case <-gossipQuery.ctx.Done():
	case <-ctx.Done():
		return context.Canceled
	}

	// Create communication terminal.
	homeTerminal, initData, tErr := docks.NewLocalCraneTerminal(crane, nil, terminal.DefaultHomeHubTerminalOpts())
	if tErr != nil {
		return tErr.Wrap("failed to create home terminal")
	}
	tErr = crane.EstablishNewTerminal(homeTerminal, initData)
	if tErr != nil {
		return tErr.Wrap("failed to connect home terminal")
	}

	if !DisableAccount {
		// Authenticate to home hub.
		authOp, tErr := access.AuthorizeToTerminal(homeTerminal)
		if tErr != nil {
			return tErr.Wrap("failed to authorize")
		}
		select {
		case tErr := <-authOp.Result:
			if !tErr.Is(terminal.ErrExplicitAck) {
				return tErr.Wrap("failed to authenticate to")
			}
		case <-time.After(3 * time.Second):
			return terminal.ErrTimeout.With("waiting for auth to complete")
		case <-ctx.Done():
			return terminal.ErrStopping
		}
	}

	// Set new home on map.
	ok := navigator.Main.SetHome(dst.ID, homeTerminal)
	if !ok {
		return errors.New("failed to set home hub on map")
	}

	// Assign crane to home hub in order to query it later.
	docks.AssignCrane(crane.ConnectedHub.ID, crane)

	success = true
	return nil
}

func optimizeNetwork(ctx *mgr.WorkerCtx) error {
	if publicIdentity == nil {
		return nil
	}

optimize:
	result, err := navigator.Main.Optimize(nil)
	if err != nil {
		if errors.Is(err, navigator.ErrEmptyMap) {
			// bootstrap to the network!
			err := bootstrapWithUpdates()
			if err != nil {
				return err
			}
			goto optimize
		}

		return err
	}

	// Create any new connections.
	var createdConnections int
	var attemptedConnections int
	for _, connectTo := range result.SuggestedConnections {
		// Skip duplicates.
		if connectTo.Duplicate {
			continue
		}

		// Check if connection already exists.
		crane := docks.GetAssignedCrane(connectTo.Hub.ID)
		if crane != nil {
			// Update last suggested timestamp.
			crane.NetState.UpdateLastSuggestedAt()
			// Continue crane if stopping.
			if crane.AbortStopping() {
				log.Infof("spn/captain: optimization aborted retiring of %s, removed stopping mark", crane)
				crane.NotifyUpdate()
			}

			// Create new connections if we have connects left.
		} else if createdConnections < result.MaxConnect {
			attemptedConnections++

			crane, tErr := EstablishPublicLane(ctx.Ctx(), connectTo.Hub)
			if !tErr.IsOK() {
				log.Warningf("spn/captain: failed to establish lane to %s: %s", connectTo.Hub, tErr)
			} else {
				createdConnections++
				crane.NetState.UpdateLastSuggestedAt()

				log.Infof("spn/captain: established lane to %s", connectTo.Hub)
			}
		}
	}

	// Log optimization result.
	if attemptedConnections > 0 {
		log.Infof(
			"spn/captain: created %d/%d new connections for %s optimization",
			createdConnections,
			attemptedConnections,
			result.Purpose)
	} else {
		log.Infof(
			"spn/captain: checked %d connections for %s optimization",
			len(result.SuggestedConnections),
			result.Purpose,
		)
	}

	// Retire cranes if unsuggested for a while.
	if result.StopOthers {
		for _, crane := range docks.GetAllAssignedCranes() {
			switch {
			case crane.Stopped():
				// Crane already stopped.
			case crane.IsStopping():
				// Crane is stopping, forcibly stop if mine and suggested.
				if crane.IsMine() && crane.NetState.StopSuggested() {
					crane.Stop(nil)
				}
			case crane.IsMine() && crane.NetState.StoppingSuggested():
				// Mark as stopping if mine and suggested.
				crane.MarkStopping()
			case crane.NetState.RequestStoppingSuggested(stopCraneAfterBeingUnsuggestedFor):
				// Mark as stopping requested.
				crane.MarkStoppingRequested()
			}
		}
	}

	return nil
}
