package networking

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"

	"nadir/internal/oscmd"

	"github.com/danielgtaylor/huma/v2"
	"github.com/danielgtaylor/huma/v2/adapters/humago"
	"github.com/danielgtaylor/huma/v2/humatest"
)

type mockBackend struct {
	name            string
	snapshotResult  IfaceConfig
	applyCalledWith IfaceConfig
	applyErr        error
	snapshotErr     error
	setUpCalled     bool
	setDownCalled   bool
}

func (m *mockBackend) Name() string { return m.name }
func (m *mockBackend) Snapshot(_ context.Context, iface string) (IfaceConfig, error) {
	return m.snapshotResult, m.snapshotErr
}
func (m *mockBackend) Apply(_ context.Context, iface string, cfg IfaceConfig) error {
	m.applyCalledWith = cfg
	return m.applyErr
}
func (m *mockBackend) SetLinkUp(_ context.Context, iface string) error {
	m.setUpCalled = true
	return nil
}
func (m *mockBackend) SetLinkDown(_ context.Context, iface string) error {
	m.setDownCalled = true
	return nil
}

func TestNetworkingHandlers(t *testing.T) {
	mux := http.NewServeMux()
	api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))

	be := &mockBackend{
		name: "mockbe",
		snapshotResult: IfaceConfig{
			Method:  "dhcp",
			Address: "192.168.1.10/24",
		},
	}
	m := &Module{be: be}
	m.Register(api)

	tempResolv := filepath.Join(t.TempDir(), "resolv.conf")
	if err := os.WriteFile(tempResolv, []byte("nameserver 1.1.1.1\nnameserver 8.8.8.8\n"), 0644); err != nil {
		t.Fatal(err)
	}
	oldResolv := resolvConf
	resolvConf = tempResolv
	defer func() { resolvConf = oldResolv }()

	oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
		if reflect.DeepEqual(args, []string{"-j", "addr"}) {
			out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.10", "prefixlen": 24}]}]`
			return oscmd.MockCommand{Stdout: out, ExitCode: 0}
		}
		if reflect.DeepEqual(args, []string{"-j", "route"}) {
			out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
			return oscmd.MockCommand{Stdout: out, ExitCode: 0}
		}
		return oscmd.MockCommand{ExitCode: 1}
	})
	defer oscmd.ClearMocks()

	// 1. Test GET /api/networking/interfaces
	resp := api.Get("/api/networking/interfaces")
	if resp.Code != http.StatusOK {
		t.Errorf("list interfaces: got %d, want %d", resp.Code, http.StatusOK)
	}

	// 1b. Test GET /api/networking/interfaces/{name} (used by edit-form prefill).
	// Asserts the backend's Snapshot output is returned verbatim as the body, so
	// the same shape can feed straight into PUT.
	resp = api.Get("/api/networking/interfaces/eth0")
	if resp.Code != http.StatusOK {
		t.Errorf("get interface: got %d, want %d", resp.Code, http.StatusOK)
	}
	var ifaceRes GetInterfaceConfigOutput
	if err := json.Unmarshal(resp.Body.Bytes(), &ifaceRes.Body); err != nil {
		t.Fatal(err)
	}
	if ifaceRes.Body.Method != "dhcp" || ifaceRes.Body.Address != "192.168.1.10/24" {
		t.Errorf("get interface: got %+v, want snapshot result", ifaceRes.Body)
	}

	// Same endpoint with no backend should return 501.
	noBackend := &Module{}
	noBackendMux := http.NewServeMux()
	noBackendAPI := humatest.Wrap(t, humago.New(noBackendMux, huma.DefaultConfig("Test", "1.0.0")))
	noBackend.Register(noBackendAPI)
	if got := noBackendAPI.Get("/api/networking/interfaces/eth0").Code; got != http.StatusNotImplemented {
		t.Errorf("get interface without backend: got %d, want 501", got)
	}

	// 2. Test GET /api/networking/routes
	resp = api.Get("/api/networking/routes")
	if resp.Code != http.StatusOK {
		t.Errorf("list routes: got %d, want %d", resp.Code, http.StatusOK)
	}

	// 3. Test GET /api/networking/dns
	resp = api.Get("/api/networking/dns")
	if resp.Code != http.StatusOK {
		t.Errorf("get dns: got %d, want %d", resp.Code, http.StatusOK)
	}
	var dnsRes DNSOutput
	if err := json.Unmarshal(resp.Body.Bytes(), &dnsRes.Body); err != nil {
		t.Fatal(err)
	}
	if len(dnsRes.Body.Servers) != 2 || dnsRes.Body.Servers[0] != "1.1.1.1" {
		t.Errorf("get dns output: %+v", dnsRes.Body)
	}

	// 4. Test PUT /api/networking/interfaces/{name}
	applyPayload := struct {
		Method          string   `json:"method"`
		Address         string   `json:"address,omitempty"`
		Prefix          int      `json:"prefix,omitempty"`
		Gateway         string   `json:"gateway,omitempty"`
		DNS             []string `json:"dns,omitempty"`
		RollbackSeconds int      `json:"rollback_seconds,omitempty"`
	}{
		Method:          "static",
		Address:         "192.168.1.20",
		Prefix:          24,
		Gateway:         "192.168.1.1",
		DNS:             []string{"1.1.1.1"},
		RollbackSeconds: 2,
	}

	resp = api.Put("/api/networking/interfaces/eth0", applyPayload)
	if resp.Code != http.StatusOK {
		t.Errorf("apply interface config: got %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
	}

	resp = api.Get("/api/networking/pending")
	if resp.Code != http.StatusOK {
		t.Errorf("get pending: got %d, want %d", resp.Code, http.StatusOK)
	}

	// 5. Test POST /api/networking/interfaces/{name}/confirm
	resp = api.Post("/api/networking/interfaces/eth0/confirm", struct{}{})
	if resp.Code != http.StatusOK {
		t.Errorf("confirm change: got %d, want %d", resp.Code, http.StatusOK)
	}

	resp = api.Get("/api/networking/pending")
	if resp.Code != http.StatusNotFound {
		t.Errorf("pending change should be cleared: got %d, want %d", resp.Code, http.StatusNotFound)
	}

	// 6. Test explicit rollback (same revert path as automatic rollback).
	applyPayload.RollbackSeconds = 1
	resp = api.Put("/api/networking/interfaces/eth0", applyPayload)
	if resp.Code != http.StatusOK {
		t.Errorf("apply config again: got %d, want %d", resp.Code, http.StatusOK)
	}
	if m.pending == nil {
		t.Fatal("expected pending change after apply")
	}
	if err := m.rollbackNow("eth0"); err != nil {
		t.Fatal(err)
	}

	resp = api.Get("/api/networking/pending")
	if resp.Code != http.StatusNotFound {
		t.Errorf("pending change should be rolled back: got %d, want %d", resp.Code, http.StatusNotFound)
	}

	if be.applyCalledWith.Method != "dhcp" || be.applyCalledWith.Address != "192.168.1.10/24" {
		t.Errorf("rollback failed to restore prior config: %+v", be.applyCalledWith)
	}

	// 7. Test POST link up & down
	resp = api.Post("/api/networking/interfaces/eth0/up", struct{}{})
	if resp.Code != http.StatusOK {
		t.Errorf("set link up: got %d, want %d", resp.Code, http.StatusOK)
	}
	if !be.setUpCalled {
		t.Errorf("expected SetLinkUp call on backend")
	}

	resp = api.Post("/api/networking/interfaces/eth0/down", struct{}{})
	if resp.Code != http.StatusOK {
		t.Errorf("set link down: got %d, want %d", resp.Code, http.StatusOK)
	}
	if !be.setDownCalled {
		t.Errorf("expected SetLinkDown call on backend")
	}
}

// #3: a failed Apply must restore the prior snapshot (no half-applied config
// left with no auto-revert) and arm no pending change.
func TestApplyFailureRestoresPrior(t *testing.T) {
	be := &mockBackend{
		name:           "mock",
		snapshotResult: IfaceConfig{Method: "dhcp"},
		applyErr:       errors.New("con up failed"),
	}
	m := &Module{be: be}

	_, err := m.startRollback(t.Context(), "eth0", IfaceConfig{Method: "static", Address: "10.0.0.5", Prefix: 24})
	if err == nil {
		t.Fatal("expected apply to fail")
	}
	// The last Apply call should be the restore-to-prior (dhcp), not the new config.
	if be.applyCalledWith.Method != "dhcp" {
		t.Errorf("expected restore to prior config, last Apply got %+v", be.applyCalledWith)
	}
	if m.pending != nil {
		t.Error("no pending change should be armed after a failed apply")
	}
}

// #1: link-down must go through the rollback safety net (arms a pending change),
// and rolling it back brings the interface back up. #2: a change to another
// interface while one is pending is rejected with errAlreadyPending.
func TestLinkDownArmsRollback(t *testing.T) {
	be := &mockBackend{name: "mock"}
	m := &Module{be: be}

	secs, err := m.startLinkDown(t.Context(), "eth0")
	if err != nil {
		t.Fatal(err)
	}
	if secs != defaultRollbackSeconds {
		t.Errorf("seconds = %d, want default %d", secs, defaultRollbackSeconds)
	}
	if !be.setDownCalled {
		t.Error("expected SetLinkDown to be called")
	}
	if m.pending == nil {
		t.Fatal("expected a pending change to be armed")
	}

	// A concurrent change to a different interface is rejected (global lock).
	if _, err := m.startRollback(t.Context(), "eth1", IfaceConfig{Method: "dhcp"}); !errors.Is(err, errAlreadyPending) {
		t.Errorf("expected errAlreadyPending for eth1, got %v", err)
	}

	// Rolling back brings the link back up and clears the pending change.
	if err := m.rollbackNow("eth0"); err != nil {
		t.Fatal(err)
	}
	if !be.setUpCalled {
		t.Error("expected SetLinkUp on rollback")
	}
	if m.pending != nil {
		t.Error("pending change should be cleared after rollback")
	}
}

func TestBackendImplementations(t *testing.T) {
	tempDir := t.TempDir()

	// Mock ip command for fallback snapshots and link control
	oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
		argStr := strings.Join(args, " ")
		if strings.Contains(argStr, "addr show") {
			out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.30", "prefixlen": 24}]}]`
			return oscmd.MockCommand{Stdout: out, ExitCode: 0}
		}
		if strings.Contains(argStr, "route show") {
			out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
			return oscmd.MockCommand{Stdout: out, ExitCode: 0}
		}
		if strings.Contains(argStr, "link set") {
			return oscmd.MockCommand{ExitCode: 0}
		}
		return oscmd.MockCommand{ExitCode: 1}
	})
	defer oscmd.ClearMocks()

	// 1. Test nmcliBackend
	nm := &nmcliBackend{}
	oscmd.SetMock("nmcli", func(args []string) oscmd.MockCommand {
		argStr := strings.Join(args, " ")
		t.Logf("nmcli mock called with: %s", argStr)
		if strings.Contains(argStr, "con show --active") {
			return oscmd.MockCommand{Stdout: "myconn:eth0\n", ExitCode: 0}
		}
		if strings.Contains(argStr, "con show") && strings.Contains(argStr, "myconn") {
			showOut := "ipv4.method:manual\nipv4.addresses:192.168.1.10/24\nipv4.gateway:192.168.1.1\nipv4.dns:1.1.1.1\nipv4.routes:10.0.0.0/24 192.168.1.1\n"
			return oscmd.MockCommand{Stdout: showOut, ExitCode: 0}
		}
		if (strings.Contains(argStr, "con modify") || strings.Contains(argStr, "con up") || strings.Contains(argStr, "con down")) && strings.Contains(argStr, "myconn") {
			return oscmd.MockCommand{ExitCode: 0}
		}
		return oscmd.MockCommand{ExitCode: 1}
	})

	cfg, err := nm.Snapshot(t.Context(), "eth0")
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Address != "192.168.1.10" || cfg.Gateway != "192.168.1.1" {
		t.Errorf("nmcli snapshot failed: %+v", cfg)
	}

	err = nm.Apply(t.Context(), "eth0", IfaceConfig{
		Method:  "static",
		Address: "192.168.1.20",
		Prefix:  24,
		Gateway: "192.168.1.1",
	})
	if err != nil {
		t.Fatal(err)
	}

	if err := nm.SetLinkUp(t.Context(), "eth0"); err != nil {
		t.Fatal(err)
	}
	if err := nm.SetLinkDown(t.Context(), "eth0"); err != nil {
		t.Fatal(err)
	}

	// 2. Test networkdBackend
	nd := &networkdBackend{}
	oldNdDir := networkdDir
	networkdDir = tempDir
	defer func() { networkdDir = oldNdDir }()

	oscmd.SetMock("networkctl", func(args []string) oscmd.MockCommand {
		return oscmd.MockCommand{ExitCode: 0}
	})

	err = nd.Apply(t.Context(), "eth0", IfaceConfig{
		Method:  "static",
		Address: "192.168.1.30",
		Prefix:  24,
		Gateway: "192.168.1.1",
		DNS:     []string{"8.8.8.8"},
	})
	if err != nil {
		t.Fatal(err)
	}

	cfg, err = nd.Snapshot(t.Context(), "eth0")
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Address != "192.168.1.30" || cfg.Method != "static" {
		t.Errorf("networkd snapshot failed: %+v", cfg)
	}

	if err := nd.SetLinkUp(t.Context(), "eth0"); err != nil {
		t.Fatal(err)
	}
	if err := nd.SetLinkDown(t.Context(), "eth0"); err != nil {
		t.Fatal(err)
	}

	// 3. Test ifupdownBackend
	iu := &ifupdownBackend{}
	oldIfaceFile := interfacesFile
	oldInterfacesDDir := interfacesDDir
	interfacesFile = filepath.Join(tempDir, "interfaces")
	interfacesDDir = filepath.Join(tempDir, "interfaces.d")
	defer func() {
		interfacesFile = oldIfaceFile
		interfacesDDir = oldInterfacesDDir
	}()

	if err := os.MkdirAll(interfacesDDir, 0755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(interfacesFile, []byte("auto lo\niface lo inet loopback\n"), 0644); err != nil {
		t.Fatal(err)
	}

	oscmd.SetMock("ifup", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })
	oscmd.SetMock("ifdown", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })

	err = iu.Apply(t.Context(), "eth0", IfaceConfig{
		Method:  "static",
		Address: "192.168.1.40",
		Prefix:  24,
		Gateway: "192.168.1.1",
		DNS:     []string{"1.1.1.1"},
	})
	if err != nil {
		t.Fatal(err)
	}

	cfg, err = iu.Snapshot(t.Context(), "eth0")
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Address != "192.168.1.40" || cfg.Method != "static" {
		t.Errorf("ifupdown snapshot failed: %+v", cfg)
	}

	if err := iu.SetLinkUp(t.Context(), "eth0"); err != nil {
		t.Fatal(err)
	}
	if err := iu.SetLinkDown(t.Context(), "eth0"); err != nil {
		t.Fatal(err)
	}
}

func TestGetInterfaceConfigAugment(t *testing.T) {
	mux := http.NewServeMux()
	api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))

	be := &mockBackend{
		name: "mockbe",
		snapshotResult: IfaceConfig{
			Method: "dhcp",
		},
	}
	m := &Module{be: be}
	m.Register(api)

	tempResolv := filepath.Join(t.TempDir(), "resolv.conf")
	if err := os.WriteFile(tempResolv, []byte("nameserver 1.1.1.1\nnameserver 8.8.8.8\n"), 0644); err != nil {
		t.Fatal(err)
	}
	oldResolv := resolvConf
	resolvConf = tempResolv
	defer func() { resolvConf = oldResolv }()

	oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
		argStr := strings.Join(args, " ")
		if strings.Contains(argStr, "addr show") {
			out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.10", "prefixlen": 24}, {"family": "inet6", "local": "2001:db8::10", "prefixlen": 64}]}]`
			return oscmd.MockCommand{Stdout: out, ExitCode: 0}
		}
		if strings.Contains(argStr, "route show") && !strings.Contains(argStr, "-6") {
			out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
			return oscmd.MockCommand{Stdout: out, ExitCode: 0}
		}
		if strings.Contains(argStr, "-6") && strings.Contains(argStr, "route show") {
			out := `[{"dst": "default", "gateway": "2001:db8::1", "dev": "eth0"}]`
			return oscmd.MockCommand{Stdout: out, ExitCode: 0}
		}
		return oscmd.MockCommand{ExitCode: 1}
	})
	defer oscmd.ClearMocks()

	resp := api.Get("/api/networking/interfaces/eth0")
	if resp.Code != http.StatusOK {
		t.Errorf("get interface: got %d, want %d", resp.Code, http.StatusOK)
	}

	var ifaceRes GetInterfaceConfigOutput
	if err := json.Unmarshal(resp.Body.Bytes(), &ifaceRes.Body); err != nil {
		t.Fatal(err)
	}

	if ifaceRes.Body.Method != "dhcp" {
		t.Errorf("expected Method to be dhcp, got %s", ifaceRes.Body.Method)
	}
	if ifaceRes.Body.Address != "192.168.1.10" || ifaceRes.Body.Prefix != 24 {
		t.Errorf("expected augmented Address 192.168.1.10/24, got %s/%d", ifaceRes.Body.Address, ifaceRes.Body.Prefix)
	}
	if ifaceRes.Body.Gateway != "192.168.1.1" {
		t.Errorf("expected augmented Gateway 192.168.1.1, got %s", ifaceRes.Body.Gateway)
	}
	if len(ifaceRes.Body.DNS) != 2 || ifaceRes.Body.DNS[0] != "1.1.1.1" || ifaceRes.Body.DNS[1] != "8.8.8.8" {
		t.Errorf("expected augmented DNS [1.1.1.1, 8.8.8.8], got %v", ifaceRes.Body.DNS)
	}
	if ifaceRes.Body.IPv6 == nil || ifaceRes.Body.IPv6.Method != "auto" || ifaceRes.Body.IPv6.Address != "2001:db8::10" || ifaceRes.Body.IPv6.Prefix != 64 || ifaceRes.Body.IPv6.Gateway != "2001:db8::1" {
		t.Errorf("expected augmented IPv6, got %+v", ifaceRes.Body.IPv6)
	}
}
