package cli

import (
	"errors"
	"fmt"
	"net"
	"strings"
	"testing"

	"github.com/Control-D-Inc/ctrld"
)

// TestInterfaceDNSFallbackViable covers when the interface-DNS fallback may be used
// after DNS intercept fails to start.
//
// The fallback names a resolver by IP with no port, so it can only reach a listener on
// :53. Taking it with the listener on a redirect-dependent port produced a total DNS
// outage on macOS: the interface points at 127.0.0.1, mDNSResponder answers there, and
// its upstream is ctrld's own address - a resolution loop with a healthy ctrld listener
// nothing can reach. Intercept startup refuses the fallback in that case rather than
// creating it.
func TestInterfaceDNSFallbackViable(t *testing.T) {
	tests := []struct {
		name string
		lc   *ctrld.ListenerConfig
		want bool
	}{
		{
			name: "listener on 53 can be reached by interface DNS",
			lc:   &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 53},
			want: true,
		},
		{
			// The reported outage: no local resolver, so the :5354 fallback port
			// cannot be expressed by interface DNS.
			name: "listener on the fallback port cannot",
			lc:   &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354},
			want: false,
		},
		{
			name: "any other non-53 port cannot",
			lc:   &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5300},
			want: false,
		},

		{
			// Port is resolved elsewhere and defaults to 53; nothing to refuse yet.
			name: "unset port is not refused",
			lc:   &ctrld.ListenerConfig{IP: "127.0.0.1"},
			want: true,
		},
		{
			name: "no listener is not refused",
			lc:   nil,
			want: true,
		},
		{
			// A non-loopback listener on 53 is still reachable by IP.
			name: "non-loopback listener on 53",
			lc:   &ctrld.ListenerConfig{IP: "192.168.1.10", Port: 53},
			want: true,
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if got := interfaceDNSFallbackViable(tc.lc); got != tc.want {
				t.Errorf("interfaceDNSFallbackViable() = %v, want %v", got, tc.want)
			}
		})
	}
}

// interceptFallbackHarness drives setDNS() through the intercept-start failure path and
// records the side effects that decide whether the host ends up with a working
// resolver.
//
// Every host-touching step is stubbed, including the intercept start itself: this test
// runs untagged on Linux, macOS and Windows runners, where the real startDNSIntercept
// would set up pf or install an NRPT rule on the machine running the tests. Stubbing it
// also makes the precondition deterministic - the failure under test is injected rather
// than depending on the runner denying a privileged operation.
type interceptFallbackHarness struct {
	interceptCalls       int
	installedNameservers []string
	installCalls         int
	resetCalls           int
	refusals             []string
}

func newInterceptFallbackHarness(t *testing.T, lc *ctrld.ListenerConfig) *interceptFallbackHarness {
	t.Helper()
	h := &interceptFallbackHarness{}

	origStart, origInstall := startDNSInterceptFn, setDnsForRunningIfaceFn
	origReset, origFatal := resetDNSFn, refuseFallbackFatal
	origCfg, origMode, origIntercept, origHard := cfg, interceptMode, dnsIntercept, hardIntercept
	t.Cleanup(func() {
		startDNSInterceptFn, setDnsForRunningIfaceFn = origStart, origInstall
		resetDNSFn, refuseFallbackFatal = origReset, origFatal
		cfg, interceptMode, dnsIntercept, hardIntercept = origCfg, origMode, origIntercept, origHard
	})

	// Never reach the real interceptor: it would configure pf on macOS and NRPT on
	// Windows, on the machine running the tests.
	startDNSInterceptFn = func(_ *prog) error {
		h.interceptCalls++
		return errors.New("dns intercept: injected start failure")
	}
	setDnsForRunningIfaceFn = func(_ *prog, nameservers []string) *net.Interface {
		h.installCalls++
		h.installedNameservers = nameservers
		return nil
	}
	resetDNSFn = func(_ *prog, _ bool, _ bool) { h.resetCalls++ }
	refuseFallbackFatal = func(format string, v ...any) {
		h.refusals = append(h.refusals, fmt.Sprintf(format, v...))
	}

	cfg = ctrld.Config{}
	cfg.Service.InterceptMode = "dns"
	cfg.Listener = map[string]*ctrld.ListenerConfig{"0": lc}
	watchdogOff := false
	cfg.Service.DnsWatchdogEnabled = &watchdogOff
	interceptMode, dnsIntercept, hardIntercept = "dns", false, false
	return h
}

func (h *interceptFallbackHarness) run(t *testing.T) {
	t.Helper()
	p := &prog{cfg: &cfg}
	p.logger.Store(mainLog.Load())
	p.setDNS()
}

// TestSetDNSRefusesUnreachableFallback is the behaviour test for the reported outage: it
// drives the real setDNS() lifecycle rather than the classification helper alone.
//
// Deleting or bypassing the guard in setDNS makes the first case fail, because interface
// DNS then gets installed pointing at a listener that cannot answer on :53 - which is
// the resolution loop this refuses to create.
func TestSetDNSRefusesUnreachableFallback(t *testing.T) {
	t.Run("non-53 listener refuses the fallback and restores DNS", func(t *testing.T) {
		h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 5354})
		h.run(t)

		if h.interceptCalls != 1 {
			t.Fatalf("intercept start called %d time(s) through the seam, want 1 — the real platform interceptor must never run here", h.interceptCalls)
		}
		if h.installCalls != 0 {
			t.Errorf("interface DNS was installed %d time(s) for a listener on :5354 — that is the resolver loop", h.installCalls)
		}
		if h.resetCalls == 0 {
			t.Error("host DNS was not restored before refusing, leaving the interface pointed at a ctrld that is not serving")
		}
		if len(h.refusals) == 0 {
			t.Fatal("refusal was not surfaced: startup must fail loudly rather than silently skip the fallback")
		}
		if !strings.Contains(h.refusals[0], "5354") {
			t.Errorf("refusal does not name the unreachable port: %q", h.refusals[0])
		}
	})

	t.Run("listener on 53 still reaches the interface-DNS fallback", func(t *testing.T) {
		h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 53})
		h.run(t)

		if h.interceptCalls != 1 {
			t.Fatalf("intercept start called %d time(s) through the seam, want 1", h.interceptCalls)
		}
		if h.installCalls != 1 {
			t.Errorf("interface DNS installed %d time(s), want 1: a listener on :53 is reachable, so the fallback must still apply", h.installCalls)
		}
		if len(h.refusals) != 0 {
			t.Errorf("unexpected refusal for a reachable listener: %v", h.refusals)
		}
		if len(h.installedNameservers) == 0 {
			t.Error("fallback installed no nameservers")
		}
	})
}
