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

//go:build !ts_omit_captiveportal

package ipnlocal

import (
	"context"
	"time"

	"tailscale.com/health"
	"tailscale.com/net/captivedetection"
	"tailscale.com/util/clientmetric"
)

func init() {
	hookCaptivePortalHealthChange.Set(captivePortalHealthChange)
	hookCheckCaptivePortalLoop.Set(checkCaptivePortalLoop)
}

var metricCaptivePortalDetected = clientmetric.NewCounter("captiveportal_detected")

// captivePortalDetectionInterval is the duration to wait in an unhealthy state with connectivity broken
// before running captive portal detection.
const captivePortalDetectionInterval = 2 * time.Second

func captivePortalHealthChange(b *LocalBackend, state *health.State) {
	isConnectivityImpacted := false
	for _, w := range state.Warnings {
		// Ignore the captive portal warnable itself.
		if w.ImpactsConnectivity && w.WarnableCode != captivePortalWarnable.Code {
			isConnectivityImpacted = true
			break
		}
	}

	// captiveCtx can be changed, and is protected with 'mu'; grab that
	// before we start our select, below.
	//
	// It is guaranteed to be non-nil.
	b.mu.Lock()
	ctx := b.captiveCtx
	b.mu.Unlock()

	// If the context is canceled, we don't need to do anything.
	if ctx.Err() != nil {
		return
	}

	if isConnectivityImpacted {
		b.logf("health: connectivity impacted; triggering captive portal detection")

		// Ensure that we select on captiveCtx so that we can time out
		// triggering captive portal detection if the backend is shutdown.
		select {
		case b.needsCaptiveDetection <- true:
		case <-ctx.Done():
		}
	} else {
		// If connectivity is not impacted, we know for sure we're not behind a captive portal,
		// so drop any warning, and signal that we don't need captive portal detection.
		b.health.SetHealthy(captivePortalWarnable)
		select {
		case b.needsCaptiveDetection <- false:
		case <-ctx.Done():
		}
	}
}

// captivePortalWarnable is a Warnable which is set to an unhealthy state when a captive portal is detected.
var captivePortalWarnable = health.Register(&health.Warnable{
	Code:  "captive-portal-detected",
	Title: "Captive portal detected",
	// High severity, because captive portals block all traffic and require user intervention.
	Severity:            health.SeverityHigh,
	Text:                health.StaticMessage("This network requires you to log in using your web browser."),
	ImpactsConnectivity: true,
})

func checkCaptivePortalLoop(b *LocalBackend, ctx context.Context) {
	var tmr *time.Timer

	maybeStartTimer := func() {
		// If there's an existing timer, nothing to do; just continue
		// waiting for it to expire. Otherwise, create a new timer.
		if tmr == nil {
			tmr = time.NewTimer(captivePortalDetectionInterval)
		}
	}
	maybeStopTimer := func() {
		if tmr == nil {
			return
		}
		if !tmr.Stop() {
			<-tmr.C
		}
		tmr = nil
	}

	for {
		if ctx.Err() != nil {
			maybeStopTimer()
			return
		}

		// First, see if we have a signal on our "healthy" channel, which
		// takes priority over an existing timer. Because a select is
		// nondeterministic, we explicitly check this channel before
		// entering the main select below, so that we're guaranteed to
		// stop the timer before starting captive portal detection.
		select {
		case needsCaptiveDetection := <-b.needsCaptiveDetection:
			if needsCaptiveDetection {
				maybeStartTimer()
			} else {
				maybeStopTimer()
			}
		default:
		}

		var timerChan <-chan time.Time
		if tmr != nil {
			timerChan = tmr.C
		}
		select {
		case <-ctx.Done():
			// All done; stop the timer and then exit.
			maybeStopTimer()
			return
		case <-timerChan:
			// Kick off captive portal check
			b.performCaptiveDetection()
			// nil out timer to force recreation
			tmr = nil
		case needsCaptiveDetection := <-b.needsCaptiveDetection:
			if needsCaptiveDetection {
				maybeStartTimer()
			} else {
				// Healthy; cancel any existing timer
				maybeStopTimer()
			}
		}
	}
}

// shouldRunCaptivePortalDetection reports whether captive portal detection
// should be run. It is enabled by default, but can be disabled via a control
// knob. It is also only run when the user explicitly wants the backend to be
// running.
func (b *LocalBackend) shouldRunCaptivePortalDetection() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	return !b.ControlKnobs().DisableCaptivePortalDetection.Load() && b.pm.prefs.WantRunning()
}

// performCaptiveDetection checks if captive portal detection is enabled via controlknob. If so, it runs
// the detection and updates the Warnable accordingly.
func (b *LocalBackend) performCaptiveDetection() {
	if !b.shouldRunCaptivePortalDetection() {
		return
	}

	d := captivedetection.NewDetector(b.logf)
	b.mu.Lock() // for b.hostinfo
	cn := b.currentNode()
	dm := cn.DERPMap()
	preferredDERP := 0
	if b.hostinfo != nil {
		if b.hostinfo.NetInfo != nil {
			preferredDERP = b.hostinfo.NetInfo.PreferredDERP
		}
	}
	ctx := b.ctx
	netMon := b.NetMon()
	b.mu.Unlock()
	found := d.Detect(ctx, netMon, dm, preferredDERP)
	if found {
		if !b.health.IsUnhealthy(captivePortalWarnable) {
			metricCaptivePortalDetected.Add(1)
		}
		b.health.SetUnhealthy(captivePortalWarnable, health.Args{})
	} else {
		b.health.SetHealthy(captivePortalWarnable)
	}
}
