package web

import (
	"context"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"sync"
	"testing"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
	"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
	"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)

// Orchestration tests (verify-before-commit): everything runs through the netAgent / netProbeFn /
// netListFn seams — no docker, no TLS, no re-exec.

// fakeNetAgent is the netAgent seam fake: scripted add/verify results + call recording.
type fakeNetAgent struct {
	mu         sync.Mutex
	addRes     agentapi.NetStorageAddResult
	addErr     error
	verify     agentapi.NetVerifyStatus
	verifyErr  error
	addCalls   int
	removes    []string
	verifyPoll int
}

func (f *fakeNetAgent) AddNetStorage(_ context.Context, req agentapi.AddNetStorageRequest) (agentapi.NetStorageAddResult, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.addCalls++
	return f.addRes, f.addErr
}
func (f *fakeNetAgent) NetVerifyStatus(_ context.Context) (agentapi.NetVerifyStatus, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.verifyPoll++
	return f.verify, f.verifyErr
}
func (f *fakeNetAgent) RemoveNetStorage(_ context.Context, name string) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.removes = append(f.removes, name)
	return nil
}
func (f *fakeNetAgent) removed() []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]string(nil), f.removes...)
}

// okAddRes is the standard successful agent add result (units installed, verify started).
func okAddRes(name string) agentapi.NetStorageAddResult {
	return agentapi.NetStorageAddResult{
		Name: name, Protocol: "nfs", Where: settings.NetworkMountRoot + "/" + name,
		GuestPath: settings.NetworkMountRoot + "/" + name, HostUID: 101000, HostGID: 101000,
		Verify: "started", JobID: "job-1",
	}
}

// netAddReq builds the standard orchestrator input.
func netAddReq(name string) agentapi.AddNetStorageRequest {
	return agentapi.AddNetStorageRequest{
		Name: name, Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/" + name,
		MappedUID: 1000, MappedGID: 1000,
	}
}

// waitNetAdd polls the job slot until it reaches a terminal phase.
func waitNetAdd(t *testing.T, s *Server) *netAddJob {
	t.Helper()
	deadline := time.Now().Add(5 * time.Second)
	for time.Now().Before(deadline) {
		if j := s.netAdd.snapshot(); j != nil && (j.Phase == netAddPhaseDone || j.Phase == netAddPhaseFailed) {
			return j
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("net add job did not finish (last: %+v)", s.netAdd.snapshot())
	return nil
}

// networkPathCount counts registered Kind=network paths.
func networkPathCount(s *Server) int {
	n := 0
	for _, sp := range s.settings.GetStoragePaths() {
		if sp.IsNetwork() {
			n++
		}
	}
	return n
}

// --- C1: happy path — register ONLY after probe-ok, exactly once ------------------------------------
// Companion red-proof: move AddStoragePath before the probe → registeredAtProbe becomes 1 → FAIL.
func TestNetAdd_HappyPath_RegisterOnlyAfterProbe(t *testing.T) {
	s := testServer(t)
	agent := &fakeNetAgent{addRes: okAddRes("media"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
	registeredAtProbe := -1
	s.netProbeFn = func(_ context.Context, dir string) probeOutcome {
		registeredAtProbe = networkPathCount(s) // MUST be 0 — registration is the LAST step
		if dir != settings.NetworkMountRoot+"/media" {
			t.Errorf("probe dir = %q, want the guest path", dir)
		}
		return probeOutcome{OK: true}
	}

	if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
		t.Fatal("startNetAdd refused with a free slot")
	}
	job := waitNetAdd(t, s)
	if job.Phase != netAddPhaseDone {
		t.Fatalf("phase = %s (category=%s detail=%s), want done", job.Phase, job.Category, job.Detail)
	}
	if registeredAtProbe != 0 {
		t.Errorf("AddStoragePath ran BEFORE the probe (count at probe = %d, want 0)", registeredAtProbe)
	}
	if got := networkPathCount(s); got != 1 {
		t.Errorf("registered network paths = %d, want exactly 1", got)
	}
	if len(agent.removed()) != 0 {
		t.Errorf("happy path must not roll back: removes=%v", agent.removed())
	}
}

// --- C2: probe-fail ⇒ rollback + NOT registered + failed{not_writable} -------------------------------
// Companion red-proof: drop the rollback call in the probe-fail branch → the removes assertion fails.
func TestNetAdd_ProbeFail_RollsBackNotRegistered(t *testing.T) {
	s := testServer(t)
	agent := &fakeNetAgent{addRes: okAddRes("media"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
	s.netProbeFn = func(context.Context, string) probeOutcome {
		return probeOutcome{OK: false, Category: "not_writable", Detail: "uid-1000 write probe: create/write refused"}
	}

	if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
		t.Fatal("startNetAdd refused")
	}
	job := waitNetAdd(t, s)
	if job.Phase != netAddPhaseFailed || job.Category != "not_writable" {
		t.Fatalf("phase/category = %s/%s, want failed/not_writable", job.Phase, job.Category)
	}
	// The §3.2 Route-A message, with the computed host id (1000 + 100000).
	if !strings.Contains(job.Message, "minden felhasználó leképezése") || !strings.Contains(job.Message, "101000") {
		t.Errorf("not_writable message must carry the map-all-users guidance + host id 101000: %q", job.Message)
	}
	if got := agent.removed(); len(got) != 1 || got[0] != "media" {
		t.Errorf("probe-fail must roll the agent install back: removes=%v", got)
	}
	if got := networkPathCount(s); got != 0 {
		t.Errorf("a probe-failed share must NOT be registered (got %d paths)", got)
	}
}

// --- C3: agent verify failed{code} ⇒ mapped Hungarian message, NOT registered ------------------------
func TestNetAdd_AgentVerifyFailed_MappedMessage(t *testing.T) {
	s := testServer(t)
	agent := &fakeNetAgent{
		addRes: okAddRes("media"),
		verify: agentapi.NetVerifyStatus{Phase: "failed", Code: "nfs_export", Detail: "reason given by server: No such file or directory", JobID: "job-1"},
	}
	probeRan := false
	s.netProbeFn = func(context.Context, string) probeOutcome { probeRan = true; return probeOutcome{OK: true} }

	if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
		t.Fatal("startNetAdd refused")
	}
	job := waitNetAdd(t, s)
	if job.Phase != netAddPhaseFailed || job.Category != "nfs_export" {
		t.Fatalf("phase/category = %s/%s, want failed/nfs_export", job.Phase, job.Category)
	}
	// The EXACT §3.2 merged message (NFSv4 cannot distinguish not-found from not-permitted).
	want := "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizze az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét."
	if job.Message != want {
		t.Errorf("nfs_export message:\n got %q\nwant %q", job.Message, want)
	}
	if probeRan {
		t.Error("the probe must not run after a failed agent verify")
	}
	if got := networkPathCount(s); got != 0 {
		t.Errorf("a verify-failed share must NOT be registered (got %d)", got)
	}
	// The AGENT already auto-rolled-back — the controller must not double-remove on this branch.
	if got := agent.removed(); len(got) != 0 {
		t.Errorf("verify-failed is agent-rolled-back; controller removes=%v want none", got)
	}
}

// --- C4: agent "no job" after install ⇒ rollback + failed (Scenario F) --------------------------------
// Companion red-proof: treat "none" as success (or skip the rollback) → assertions fail.
func TestNetAdd_VerifyLost_RollsBack(t *testing.T) {
	s := testServer(t)
	agent := &fakeNetAgent{
		addRes: okAddRes("media"),
		verify: agentapi.NetVerifyStatus{Phase: "none"}, // the agent restarted mid-verify
	}
	s.netProbeFn = func(context.Context, string) probeOutcome {
		t.Error("probe must not run when the verify was lost")
		return probeOutcome{OK: true}
	}

	if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
		t.Fatal("startNetAdd refused")
	}
	job := waitNetAdd(t, s)
	if job.Phase != netAddPhaseFailed {
		t.Fatalf("phase = %s, want failed", job.Phase)
	}
	if got := agent.removed(); len(got) != 1 || got[0] != "media" {
		t.Errorf("verify-lost must roll back the install: removes=%v", got)
	}
	if got := networkPathCount(s); got != 0 {
		t.Errorf("a verify-lost share must NOT be registered (got %d)", got)
	}
}

// --- C5: the --netprobe child body (pure file logic, t.TempDir) ---------------------------------------
// The fstype assertion (RCA fix 2) is pinned to `network` here — these rows test the WRITE/READBACK/
// CLEANUP logic against a local tempdir, which the real classifier would (correctly) refuse as a
// stub. The assertion has its own suite: netprobe_stub_test.go.
func TestNetProbeChild(t *testing.T) {
	origClass := netProbeFSClass
	netProbeFSClass = func(string) string { return system.FSClassNetwork }
	t.Cleanup(func() { netProbeFSClass = origClass })
	t.Run("ok", func(t *testing.T) {
		dir := t.TempDir()
		if got := NetProbeChild(dir); got != netProbeExitOK {
			t.Fatalf("exit = %d, want 0", got)
		}
		entries, _ := os.ReadDir(dir)
		if len(entries) != 0 {
			t.Errorf("probe must clean up its file, left: %v", entries)
		}
	})
	t.Run("unwritable dir → exit 2 → not_writable", func(t *testing.T) {
		dir := filepath.Join(t.TempDir(), "nope") // nonexistent → create fails everywhere
		if runtime.GOOS != "windows" {
			dir = t.TempDir()
			if err := os.Chmod(dir, 0o555); err != nil { // read-only — the squash-trap shape
				t.Fatal(err)
			}
			t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
		}
		got := NetProbeChild(dir)
		if got != netProbeExitNoWrite {
			t.Fatalf("exit = %d, want %d", got, netProbeExitNoWrite)
		}
		if v := netProbeVerdict(got, ""); v.OK || v.Category != "not_writable" {
			t.Errorf("verdict = %+v, want not_writable", v)
		}
	})
	t.Run("nonce tamper → exit 3 → probe_io", func(t *testing.T) {
		orig := netProbeReadBack
		netProbeReadBack = func(path string) ([]byte, error) { return []byte("tampered"), nil }
		t.Cleanup(func() { netProbeReadBack = orig })
		got := NetProbeChild(t.TempDir())
		if got != netProbeExitMismatch {
			t.Fatalf("exit = %d, want %d", got, netProbeExitMismatch)
		}
		if v := netProbeVerdict(got, ""); v.OK || v.Category != "probe_io" {
			t.Errorf("verdict = %+v, want probe_io", v)
		}
	})
	t.Run("cleanup fail → exit 4 → OK with warn", func(t *testing.T) {
		orig := netProbeReadBack
		// Read back correctly but DELETE the file first — the child's own Remove then fails.
		netProbeReadBack = func(path string) ([]byte, error) {
			data, err := os.ReadFile(path)
			if err != nil {
				return nil, err
			}
			_ = os.Remove(path)
			return data, nil
		}
		t.Cleanup(func() { netProbeReadBack = orig })
		got := NetProbeChild(t.TempDir())
		if got != netProbeExitCleanup {
			t.Fatalf("exit = %d, want %d", got, netProbeExitCleanup)
		}
		v := netProbeVerdict(got, "")
		if !v.OK || v.Warn == "" {
			t.Errorf("cleanup-fail must be OK-with-warn (§8), got %+v", v)
		}
	})
}

// --- C6: single-flight (Scenario G) -------------------------------------------------------------------
// Companion red-proof: drop the acquire() running-check → the second add is accepted → FAIL.
func TestNetAdd_SingleFlight(t *testing.T) {
	s := testServer(t)
	release := make(chan struct{})
	agent := &fakeNetAgent{addRes: okAddRes("m1"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
	s.netAgentFn = func() (netAgent, error) { return agent, nil }
	s.netProbeFn = func(context.Context, string) probeOutcome { <-release; return probeOutcome{OK: true} }

	if !s.startNetAdd(agent, netAddReq("m1"), "NAS m1") {
		t.Fatal("first add refused")
	}
	// Second add through the HTTP handler while the first is blocked in probing.
	body := `{"name":"m2","protocol":"nfs","server":"10.0.0.6","export":"/srv/m2"}`
	r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/add", strings.NewReader(body))
	w := httptest.NewRecorder()
	s.handleNetStorageAdd(w, r)
	if w.Code != http.StatusConflict {
		t.Fatalf("second add: got %d want 409 (%s)", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "már folyamatban van egy csatlakoztatás") {
		t.Errorf("409 must carry the Hungarian busy message: %s", w.Body.String())
	}
	close(release)
	job := waitNetAdd(t, s)
	if job.Phase != netAddPhaseDone || job.Name != "m1" {
		t.Errorf("first job must finish unaffected: %+v", job)
	}
}

// --- C7: orphan surfacing — live-but-unregistered share renders as a remove-only row ------------------
// Companion red-proof: a registry-only filter (drop the live-map sweep) loses the ghost row → FAIL.
func TestNetStorage_OrphanRow(t *testing.T) {
	s := testServer(t)
	addNetworkPath(t, s, "media") // registered + live
	s.netListFn = func(context.Context) ([]agentapi.NetworkMountStatus, error) {
		return []agentapi.NetworkMountStatus{
			{Name: "media", Protocol: "nfs", Server: "10.0.0.5", Where: settings.NetworkMountRoot + "/media", Configured: true, Mounted: true, Reachable: true, Health: "ok"},
			{Name: "ghost", Protocol: "nfs", Server: "10.0.0.5", Where: settings.NetworkMountRoot + "/ghost", Configured: true, Mounted: false, Reachable: true, Health: "idle"},
		}, nil
	}
	items := s.networkStorageItems(context.Background())
	if len(items) != 2 {
		t.Fatalf("items = %d, want 2 (registered + orphan): %+v", len(items), items)
	}
	var ghost *networkStorageItem
	for i := range items {
		if items[i].Name == "ghost" {
			ghost = &items[i]
		} else if items[i].Orphan {
			t.Errorf("registered share %q must not be an orphan", items[i].Name)
		}
	}
	if ghost == nil {
		t.Fatalf("live-but-unregistered share missing from the list: %+v", items)
	}
	if !ghost.Orphan || ghost.Label != "Árva megosztás: ghost" {
		t.Errorf("ghost row must be Orphan with the árva label, got %+v", *ghost)
	}
}

// TestNetAdd_StatusEndpoint_NoJob: the poll endpoint's empty shape.
func TestNetAdd_StatusEndpoint_NoJob(t *testing.T) {
	s := testServer(t)
	r := httptest.NewRequest(http.MethodGet, "/api/storage/netstorage/add/status", nil)
	w := httptest.NewRecorder()
	s.handleNetStorageAddStatus(w, r)
	if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"phase":"none"`) {
		t.Fatalf("empty status: %d %s", w.Code, w.Body.String())
	}
}
