package stacks

import (
	"crypto/sha256"
	"encoding/hex"
	"io"
	"log"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

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

// newSambaManager builds a Manager whose samba lifecycle runs for real (render → atomic write →
// change detection) with ONLY the docker touchpoints seamed out: compose up, smbpasswd, liveness.
func newSambaManager(t *testing.T) (*Manager, *settings.Settings, string, *int) {
	t.Helper()
	root := t.TempDir()
	cfg := &config.Config{}
	cfg.Paths.StacksDir = filepath.Join(root, "stacks")

	sett, err := settings.Load(filepath.Join(root, "settings.json"), log.New(io.Discard, "", 0))
	if err != nil {
		t.Fatalf("settings.Load: %v", err)
	}
	upCalls := 0
	m := &Manager{
		cfg:        cfg,
		logger:     log.New(io.Discard, "", 0),
		stacks:     map[string]*Stack{},
		settings:   sett,
		sambaUpFn:  func(string) error { upCalls++; return nil },
		sambaRunFn: func() bool { return false },
	}
	return m, sett, root, &upCalls
}

// seedShare registers a storage root and creates a share folder with a file inside it.
func seedShare(t *testing.T, sett *settings.Settings, root, name string) string {
	t.Helper()
	storageRoot := filepath.Join(root, "drive")
	shareDir := filepath.Join(storageRoot, "shares", name)
	if err := os.MkdirAll(shareDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(shareDir, "csalad.txt"), []byte("fontos adat"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := sett.AddStoragePath(settings.StoragePath{
		Path: storageRoot, Label: "teszt", Schedulable: true,
		AddedAt: time.Now().UTC().Format(time.RFC3339),
	}); err != nil {
		t.Fatal(err)
	}
	return shareDir
}

// snapshotTree hashes every file under dir so a test can prove NOTHING changed.
func snapshotTree(t *testing.T, dir string) map[string]string {
	t.Helper()
	out := map[string]string{}
	err := filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if fi.IsDir() {
			return nil
		}
		b, err := os.ReadFile(p)
		if err != nil {
			return err
		}
		sum := sha256.Sum256(b)
		rel, _ := filepath.Rel(dir, p)
		out[rel] = hex.EncodeToString(sum[:])
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	return out
}

func enableSMB(t *testing.T, sett *settings.Settings) {
	t.Helper()
	if err := sett.SetSMBEnabled(true); err != nil {
		t.Fatal(err)
	}
	if err := sett.SetSMBUserSet(true); err != nil {
		t.Fatal(err)
	}
}

// Scenario A: enable + first share → smb.conf carries exactly that share, compose written, one up.
func TestSambaReconcile_HappyPath(t *testing.T) {
	m, sett, root, upCalls := newSambaManager(t)
	shareDir := seedShare(t, sett, root, "dokumentumok")
	enableSMB(t, sett)
	if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil {
		t.Fatal(err)
	}

	if err := m.ReconcileSamba(); err != nil {
		t.Fatalf("ReconcileSamba: %v", err)
	}
	conf, err := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf"))
	if err != nil {
		t.Fatalf("smb.conf not written: %v", err)
	}
	if !strings.Contains(string(conf), "[dokumentumok]") {
		t.Errorf("share section missing:\n%s", conf)
	}
	if !strings.Contains(string(conf), "path = "+shareDir) {
		t.Errorf("share path missing:\n%s", conf)
	}
	if !strings.Contains(string(conf), "force user = felhom") {
		t.Error("force user block missing")
	}
	if _, err := os.Stat(filepath.Join(m.sambaDir(), "docker-compose.yml")); err != nil {
		t.Errorf("compose not written: %v", err)
	}
	if *upCalls != 1 {
		t.Errorf("expected exactly 1 compose up, got %d", *upCalls)
	}
}

// §10 idempotency: re-running with an UNCHANGED registry while the container runs performs ZERO
// compose calls (the fake seam is the assertion).
func TestSambaReconcile_IdempotentNoComposeCall(t *testing.T) {
	m, sett, root, upCalls := newSambaManager(t)
	shareDir := seedShare(t, sett, root, "dokumentumok")
	enableSMB(t, sett)
	if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil {
		t.Fatal(err)
	}
	if err := m.ReconcileSamba(); err != nil {
		t.Fatal(err)
	}
	if *upCalls != 1 {
		t.Fatalf("setup: expected 1 up, got %d", *upCalls)
	}
	// Container now running + config unchanged → the re-run must be a pure no-op.
	m.sambaRunFn = func() bool { return true }
	before := *upCalls
	if err := m.ReconcileSamba(); err != nil {
		t.Fatal(err)
	}
	if *upCalls != before {
		t.Errorf("unchanged registry must perform NO compose call: calls went %d → %d", before, *upCalls)
	}
	// A real change must still apply.
	if err := sett.SetSMBShareOffsite("dokumentumok", false); err != nil {
		t.Fatal(err)
	}
	if err := sett.RemoveSMBShare("dokumentumok"); err != nil {
		t.Fatal(err)
	}
	if err := m.ReconcileSamba(); err != nil {
		t.Fatal(err)
	}
	if *upCalls != before+1 {
		t.Errorf("a changed registry must trigger exactly one compose up, got %d", *upCalls-before)
	}
}

// Edge case: a share on a DISCONNECTED storage path is never exported (config retained).
func TestSambaReconcile_DeadMountOmitted(t *testing.T) {
	m, sett, root, _ := newSambaManager(t)
	shareDir := seedShare(t, sett, root, "filmek")
	enableSMB(t, sett)
	if err := sett.AddSMBShare(settings.SMBShare{Name: "filmek", Path: shareDir, Offsite: true}); err != nil {
		t.Fatal(err)
	}
	if err := sett.SetDisconnected(filepath.Join(root, "drive"), true, nil); err != nil {
		t.Fatal(err)
	}
	if err := m.ReconcileSamba(); err != nil {
		t.Fatal(err)
	}
	conf, err := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf"))
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(conf), "[filmek]") {
		t.Errorf("a share on a disconnected drive must NOT be exported:\n%s", conf)
	}
	// The share config itself is retained (only the export is withheld).
	if len(sett.GetSMBShares()) != 1 {
		t.Error("share config must be retained while the drive is away")
	}
}

// Scenario E: deleting a share and disabling sharing NEVER touch the folder or its contents.
func TestSambaShareDeleteAndDisableKeepData(t *testing.T) {
	m, sett, root, _ := newSambaManager(t)
	shareDir := seedShare(t, sett, root, "dokumentumok")
	enableSMB(t, sett)
	if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil {
		t.Fatal(err)
	}
	if err := m.ReconcileSamba(); err != nil {
		t.Fatal(err)
	}
	before := snapshotTree(t, shareDir)

	// Delete the share → section gone, data identical.
	if err := sett.RemoveSMBShare("dokumentumok"); err != nil {
		t.Fatal(err)
	}
	if err := m.ReconcileSamba(); err != nil {
		t.Fatal(err)
	}
	conf, _ := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf"))
	if strings.Contains(string(conf), "[dokumentumok]") {
		t.Error("deleted share must be absent from smb.conf")
	}
	if got := snapshotTree(t, shareDir); !sameTree(before, got) {
		t.Errorf("share delete must not touch the folder:\nbefore=%v\nafter=%v", before, got)
	}

	// Disable sharing → data still identical.
	if err := sett.SetSMBEnabled(false); err != nil {
		t.Fatal(err)
	}
	if err := m.ReconcileSamba(); err != nil {
		t.Fatal(err)
	}
	if got := snapshotTree(t, shareDir); !sameTree(before, got) {
		t.Errorf("disabling sharing must not touch the folder:\nbefore=%v\nafter=%v", before, got)
	}
}

func sameTree(a, b map[string]string) bool {
	if len(a) != len(b) {
		return false
	}
	for k, v := range a {
		if b[k] != v {
			return false
		}
	}
	return true
}

// Rule 4: the household SMB password reaches smbpasswd's stdin and NOTHING else — settings.json
// must carry only the UserSet boolean.
func TestSambaPasswordNeverPersisted(t *testing.T) {
	m, sett, root, _ := newSambaManager(t)
	enableSMB(t, sett)
	if err := sett.SetSMBUserSet(false); err != nil {
		t.Fatal(err)
	}

	const secret = "TitkosJelszo123"
	var seen string
	m.sambaPasswdFn = func(pw string) error { seen = pw; return nil }

	if err := m.SetSMBPassword(secret); err != nil {
		t.Fatalf("SetSMBPassword: %v", err)
	}
	if seen != secret {
		t.Errorf("password did not reach the smbpasswd seam: got %q", seen)
	}
	if !sett.GetSMBSettings().UserSet {
		t.Error("UserSet must be recorded after a successful password apply")
	}

	// The secret must appear in NO persisted artefact: settings.json, smb.conf, or compose.
	for _, p := range []string{
		filepath.Join(root, "settings.json"),
		filepath.Join(m.sambaDir(), "smb.conf"),
		filepath.Join(m.sambaDir(), "docker-compose.yml"),
	} {
		b, err := os.ReadFile(p)
		if err != nil {
			continue // not all files exist in every path
		}
		if strings.Contains(string(b), secret) {
			t.Errorf("SMB password leaked into %s", p)
		}
	}
}

// A password apply is refused while the feature is off (no container should ever be started for it).
func TestSambaPasswordRefusedWhenDisabled(t *testing.T) {
	m, _, _, upCalls := newSambaManager(t)
	m.sambaPasswdFn = func(string) error { t.Fatal("smbpasswd must not run while disabled"); return nil }
	if err := m.SetSMBPassword("x"); err == nil {
		t.Error("setting a password while sharing is disabled must be refused")
	}
	if *upCalls != 0 {
		t.Errorf("no compose call may happen while disabled, got %d", *upCalls)
	}
}
