package localapi

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)

// Verify-job tests (SPIKE-nas-verify): the detached pipeline runs entirely through seams — no
// systemctl, no journalctl, no /proc/mounts, no network. fakeNetOps (netstorage_test.go) records
// the mount-surface effects the assertions check.

// verifySeams configures a Server's verify pipeline for tests.
type verifySeams struct {
	trigger   func(where string) error
	mounted   func(where string) bool
	journal   func(ctx context.Context, unit string) (string, error)
	reachable func(proto storage.NetworkProtocol, server string) bool
}

func newVerifyServer(t *testing.T, n NetworkStorageOps, credsDir string, seams verifySeams) *Server {
	t.Helper()
	srv := newNetServer(t, n, credsDir)
	if seams.trigger != nil {
		srv.netTrigger = seams.trigger
	} else {
		srv.netTrigger = func(string) error { return nil } // default: instant, successful read
	}
	if seams.mounted != nil {
		srv.netMounted = seams.mounted
	}
	if seams.journal != nil {
		srv.netJournal = seams.journal
	} else {
		srv.netJournal = func(context.Context, string) (string, error) { return "", nil }
	}
	if seams.reachable != nil {
		srv.netReachable = seams.reachable
	} else {
		srv.netReachable = func(storage.NetworkProtocol, string) bool { return true }
	}
	return srv
}

// pollVerify polls GET /netstorage/verify-status until the job leaves `running` (or the deadline).
func pollVerify(t *testing.T, h http.Handler) map[string]any {
	t.Helper()
	deadline := time.Now().Add(3 * time.Second)
	for time.Now().Before(deadline) {
		w := do(t, h, "GET", "/netstorage/verify-status", "A", "")
		if w.Code != http.StatusOK {
			t.Fatalf("verify-status: got %d (%s)", w.Code, w.Body.String())
		}
		var resp struct {
			Data map[string]any `json:"data"`
		}
		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
			t.Fatalf("decode status: %v", err)
		}
		if p, _ := resp.Data["phase"].(string); p != netVerifyPhaseRunning {
			return resp.Data
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatal("verify job did not reach a terminal phase in time")
	return nil
}

// --- A3: mount-FAILED ⇒ auto-rollback (units + creds) + failed{code} ---------------------------------
// Companion red-proof: drop the s.rollbackNetMount call from runNetVerify → the removed/creds
// assertions fail (the failed install would linger — today's bug shape).
func TestNetVerify_MountFailed_RollsBackAndClassifies(t *testing.T) {
	credsDir := t.TempDir()
	n := &fakeNetOps{}
	srv := newVerifyServer(t, n, credsDir, verifySeams{
		mounted: func(string) bool { return false }, // §8: not in /proc/mounts ⇒ FAILED
		journal: func(context.Context, string) (string, error) {
			return "mount error(13): Permission denied", nil // Q4 iv — smb_auth
		},
	})
	h := srv.Handler()

	body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000,"username":"u","password":"p"}`
	w := do(t, h, "POST", "/netstorage/add", "A", body)
	if w.Code != http.StatusOK {
		t.Fatalf("add: got %d (%s)", w.Code, w.Body.String())
	}
	if len(n.ensured) != 1 {
		t.Fatalf("EnsureNetworkMount calls = %d, want 1", len(n.ensured))
	}

	final := pollVerify(t, h)
	if final["phase"] != netVerifyPhaseFailed {
		t.Fatalf("phase = %v, want failed (%v)", final["phase"], final)
	}
	if final["code"] != storage.NetVerifySMBAuth {
		t.Errorf("code = %v, want %q", final["code"], storage.NetVerifySMBAuth)
	}
	// The auto-rollback effects: the unit removal was called AND the creds file is gone.
	if len(n.removed) != 1 || n.removed[0] != "vids" {
		t.Errorf("RemoveNetworkMount not called for the failed install: removed=%v", n.removed)
	}
	if _, err := os.Stat(filepath.Join(credsDir, "vids.cred")); !os.IsNotExist(err) {
		t.Errorf("creds file must be removed on verify failure (stat err = %v)", err)
	}
}

// TestNetVerify_JournalUnavailable_DegradesButStillRollsBack: journal read error ⇒ generic
// mount_failed + the systemd-journal hint, and the rollback still runs (Scenario B degradation).
func TestNetVerify_JournalUnavailable_DegradesButStillRollsBack(t *testing.T) {
	n := &fakeNetOps{}
	srv := newVerifyServer(t, n, t.TempDir(), verifySeams{
		mounted: func(string) bool { return false },
		journal: func(context.Context, string) (string, error) {
			return "", fmt.Errorf("journalctl: permission denied")
		},
	})
	h := srv.Handler()
	w := do(t, h, "POST", "/netstorage/add", "A",
		`{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`)
	if w.Code != http.StatusOK {
		t.Fatalf("add: got %d (%s)", w.Code, w.Body.String())
	}
	final := pollVerify(t, h)
	if final["phase"] != netVerifyPhaseFailed || final["code"] != storage.NetVerifyMountFailed {
		t.Fatalf("degraded classification: phase=%v code=%v, want failed/%q", final["phase"], final["code"], storage.NetVerifyMountFailed)
	}
	detail, _ := final["detail"].(string)
	if want := "systemd-journal"; !containsStr(detail, want) {
		t.Errorf("degraded detail must carry the group hint %q: %q", want, detail)
	}
	if len(n.removed) != 1 {
		t.Errorf("rollback must still run when the journal is unavailable: removed=%v", n.removed)
	}
}

// --- A4: the §8 truth table — /proc/mounts is the ONLY judge -----------------------------------------
// Companion red-proof: a readability-based verdict (trigger err == nil ⇒ OK) fails BOTH rows.
func TestNetVerify_TruthTable(t *testing.T) {
	t.Run("ReadDir ok but NOT mounted = FAILED (empty-dir false positive guard)", func(t *testing.T) {
		n := &fakeNetOps{}
		srv := newVerifyServer(t, n, t.TempDir(), verifySeams{
			trigger: func(string) error { return nil },  // the read "worked" (empty dir)
			mounted: func(string) bool { return false }, // …but no nfs4/cifs in /proc/mounts
			journal: func(context.Context, string) (string, error) { return "", nil },
		})
		h := srv.Handler()
		w := do(t, h, "POST", "/netstorage/add", "A",
			`{"name":"m1","protocol":"nfs","server":"10.0.0.5","export":"/srv/m1","mapped_uid":1000,"mapped_gid":1000}`)
		if w.Code != http.StatusOK {
			t.Fatalf("add: %d (%s)", w.Code, w.Body.String())
		}
		final := pollVerify(t, h)
		if final["phase"] != netVerifyPhaseFailed {
			t.Fatalf("a readable-but-unmounted path must FAIL verify, got %v", final["phase"])
		}
		if len(n.removed) != 1 {
			t.Errorf("failed verify must roll back: removed=%v", n.removed)
		}
	})
	t.Run("ReadDir EACCES but mounted = OK (0700 export on a good mount)", func(t *testing.T) {
		n := &fakeNetOps{}
		srv := newVerifyServer(t, n, t.TempDir(), verifySeams{
			trigger: func(string) error { return os.ErrPermission }, // agent user can't read it — fine
			mounted: func(string) bool { return true },              // the mount is REAL
		})
		h := srv.Handler()
		w := do(t, h, "POST", "/netstorage/add", "A",
			`{"name":"m2","protocol":"nfs","server":"10.0.0.5","export":"/srv/m2","mapped_uid":1000,"mapped_gid":1000}`)
		if w.Code != http.StatusOK {
			t.Fatalf("add: %d (%s)", w.Code, w.Body.String())
		}
		final := pollVerify(t, h)
		if final["phase"] != netVerifyPhaseDone {
			t.Fatalf("an unreadable-but-mounted share must PASS agent verify (writability is the controller probe's job), got %v (%v)", final["phase"], final)
		}
		if len(n.removed) != 0 {
			t.Errorf("a passing verify must NOT roll back: removed=%v", n.removed)
		}
	})
}

// --- A5: the sync TCP pre-probe refuses BEFORE any install (Scenario E) ------------------------------
// Companion red-proof: move the pre-probe after EnsureNetworkMount → the zero-install assertion fails.
func TestNetVerify_UnreachablePreProbe_InstallsNothing(t *testing.T) {
	credsDir := t.TempDir()
	n := &fakeNetOps{}
	srv := newVerifyServer(t, n, credsDir, verifySeams{
		reachable: func(storage.NetworkProtocol, string) bool { return false },
	})
	h := srv.Handler()
	w := do(t, h, "POST", "/netstorage/add", "A",
		`{"name":"vids","protocol":"smb","server":"192.168.0.199","export":"vids","mapped_uid":1000,"mapped_gid":1000,"username":"u","password":"p"}`)
	if w.Code != http.StatusBadGateway {
		t.Fatalf("unreachable add: got %d want 502 (%s)", w.Code, w.Body.String())
	}
	var resp struct {
		OK   bool           `json:"ok"`
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if resp.OK || resp.Data["code"] != storage.NetVerifyUnreachable {
		t.Errorf("refusal must carry code=unreachable: %s", w.Body.String())
	}
	// NOTHING installed: no Ensure call, no creds file, and the verify slot is free again.
	if len(n.ensured) != 0 {
		t.Errorf("EnsureNetworkMount must not run for an unreachable server: %v", n.ensured)
	}
	if _, err := os.Stat(filepath.Join(credsDir, "vids.cred")); !os.IsNotExist(err) {
		t.Errorf("no creds file may be written for an unreachable server (stat err = %v)", err)
	}
	if got := srv.netVerifySnapshot(); got != nil {
		t.Errorf("the verify slot must be released after a sync refusal: %+v", got)
	}
}

// --- A6: single-flight + the "no job" shape ----------------------------------------------------------
// Companion red-proof: drop the tryStartNetVerify running-check → the 409 assertion fails.
func TestNetVerify_SingleFlight_AndNoJobShape(t *testing.T) {
	// No job yet: the status endpoint must serve the Scenario-F "none" shape.
	nIdle := &fakeNetOps{}
	hIdle := newVerifyServer(t, nIdle, t.TempDir(), verifySeams{}).Handler()
	w := do(t, hIdle, "GET", "/netstorage/verify-status", "A", "")
	var idle struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &idle); err != nil {
		t.Fatal(err)
	}
	if idle.Data["phase"] != netVerifyPhaseNone {
		t.Fatalf("empty slot must report phase %q, got %v", netVerifyPhaseNone, idle.Data["phase"])
	}

	// Single-flight: hold the first verify open via a blocking trigger, then submit a second add.
	release := make(chan struct{})
	n := &fakeNetOps{}
	srv := newVerifyServer(t, n, t.TempDir(), verifySeams{
		trigger: func(string) error { <-release; return nil },
		mounted: func(string) bool { return true },
	})
	h := srv.Handler()
	w1 := do(t, h, "POST", "/netstorage/add", "A",
		`{"name":"m1","protocol":"nfs","server":"10.0.0.5","export":"/srv/m1","mapped_uid":1000,"mapped_gid":1000}`)
	if w1.Code != http.StatusOK {
		t.Fatalf("first add: %d (%s)", w1.Code, w1.Body.String())
	}
	w2 := do(t, h, "POST", "/netstorage/add", "A",
		`{"name":"m2","protocol":"nfs","server":"10.0.0.6","export":"/srv/m2","mapped_uid":1000,"mapped_gid":1000}`)
	if w2.Code != http.StatusConflict {
		t.Fatalf("second add while verifying: got %d want 409 (%s)", w2.Code, w2.Body.String())
	}
	if len(n.ensured) != 1 {
		t.Errorf("the refused second add must not reach EnsureNetworkMount: %v", n.ensured)
	}
	close(release)
	final := pollVerify(t, h)
	if final["phase"] != netVerifyPhaseDone || final["name"] != "m1" {
		t.Errorf("first job must finish unaffected: %v", final)
	}
}

func containsStr(s, sub string) bool { return strings.Contains(s, sub) }
