package backup

import (
	"io"
	"log"
	"path/filepath"
	"strings"
	"testing"

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

// volDumpFakeProvider is a StackDataProvider for the runVolumeDumps gating tests: a configurable
// stack list with per-stack volumes + drive, and a StopStack recorder (the destructive act the
// gates must prevent for volume-less/protected stacks).
type volDumpFakeProvider struct {
	stacks  []StackSummary
	volumes map[string][]string
	hdd     map[string]string
	stopped []string
}

func (f *volDumpFakeProvider) GetStackComposePath(string) (string, bool) { return "", false }
func (f *volDumpFakeProvider) ListDeployedStacks() []StackSummary        { return f.stacks }
func (f *volDumpFakeProvider) GetStackHDDMounts(string) []string         { return nil }
func (f *volDumpFakeProvider) GetStackHDDPath(name string) string        { return f.hdd[name] }
func (f *volDumpFakeProvider) GetDockerVolumes(name string) []string     { return f.volumes[name] }
func (f *volDumpFakeProvider) StopStack(name string) error {
	f.stopped = append(f.stopped, name)
	return nil
}
func (f *volDumpFakeProvider) StartStack(string) error          { return nil }
func (f *volDumpFakeProvider) RefreshAndIsRunning(string) bool  { return true }
func (f *volDumpFakeProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
	return RecoveryInfo{}, false
}
func (f *volDumpFakeProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (f *volDumpFakeProvider) RecreateStackFromUnit(string, string, map[string]string) error {
	return nil
}

// TestRunVolumeDumps_GatesPrecedeDump proves Scenario D/E's gating: the dump is invoked ONLY for
// a volume-bearing, unprotected stack on a writable drive. The negatives are the point —
// volume-less (rallly-like), protected (traefik-like), and disconnected-drive stacks are never
// dumped (and therefore never stopped, since stopping happens inside DumpAppVolumesSafe).
// COMPANION red-proof: removing the volume gate makes the seam fire for "rallly" → this fails.
func TestRunVolumeDumps_GatesPrecedeDump(t *testing.T) {
	tmp := t.TempDir()
	usbDrive := filepath.Join(tmp, "usb")
	badDrive := filepath.Join(tmp, "gone")

	sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
	if err != nil {
		t.Fatal(err)
	}
	if err := sett.AddStoragePath(settings.StoragePath{Path: badDrive, Label: "gone"}); err != nil {
		t.Fatal(err)
	}
	if err := sett.SetDisconnected(badDrive, true, nil); err != nil {
		t.Fatal(err)
	}

	cfg := &config.Config{}
	cfg.Paths.SystemDataPath = filepath.Join(tmp, "sys")
	cfg.Stacks.Protected = []string{"traefik"}

	fake := &volDumpFakeProvider{
		stacks: []StackSummary{
			{Name: "nextcloud"}, {Name: "rallly"}, {Name: "traefik"}, {Name: "diskapp"},
		},
		volumes: map[string][]string{
			"nextcloud": {"nextcloud_nextcloud_html"},
			"rallly":    nil,                        // volume-less — must NOT be stopped/dumped
			"traefik":   {"traefik_data"},           // protected — never considered
			"diskapp":   {"diskapp_data"},           // volume-bearing but drive disconnected
		},
		hdd: map[string]string{"nextcloud": usbDrive, "diskapp": badDrive},
	}

	m := &Manager{cfg: cfg, settings: sett, logger: log.New(io.Discard, "", 0),
		systemDataPath: cfg.Paths.SystemDataPath, stackProvider: fake}
	var dumpCalls []string
	m.dumpVolumesSafe = func(name string) error {
		dumpCalls = append(dumpCalls, name)
		return nil
	}

	summary, dumped, ok := m.runVolumeDumps()
	if !ok {
		t.Fatalf("run should be ok, summary=%v", summary)
	}
	if len(dumpCalls) != 1 || dumpCalls[0] != "nextcloud" {
		t.Errorf("dump invoked for %v, want exactly [nextcloud] (gates must exclude volume-less/protected/disconnected)", dumpCalls)
	}
	if dumped != 1 {
		t.Errorf("dumped = %d, want 1", dumped)
	}
	if len(fake.stopped) != 0 {
		t.Errorf("StopStack called for %v — the seam bypasses the real dump, so ANY stop means a gate leaked", fake.stopped)
	}
	// The disconnected drive appears as a SKIP in the summary (same style as the DB loop).
	if !containsSummary(summary, "SKIP diskapp volumes (drive disconnected)") {
		t.Errorf("summary missing disconnected SKIP entry: %v", summary)
	}
}

// TestRunVolumeDumps_VolumelessNeverStopped drives the REAL DumpAppVolumesSafe path (no seam) with
// only volume-less/protected stacks: the volume gate must keep them from ever being stopped. This
// is the direct Scenario D negative — without the gate, DumpAppVolumesSafe stops the stack BEFORE
// its own volume check, so this test fails with stopped=[rallly].
func TestRunVolumeDumps_VolumelessNeverStopped(t *testing.T) {
	cfg := &config.Config{}
	cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys")
	cfg.Stacks.Protected = []string{"traefik"}

	fake := &volDumpFakeProvider{
		stacks:  []StackSummary{{Name: "rallly"}, {Name: "traefik"}},
		volumes: map[string][]string{"traefik": {"traefik_data"}},
	}
	m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0),
		systemDataPath: cfg.Paths.SystemDataPath, stackProvider: fake}
	// deliberately NO seam: the real DumpAppVolumesSafe would record StopStack on the fake.

	if _, dumped, ok := m.runVolumeDumps(); !ok || dumped != 0 {
		t.Fatalf("expected clean zero-dump run, dumped=%d ok=%v", dumped, ok)
	}
	if len(fake.stopped) != 0 {
		t.Errorf("volume-less/protected stacks were stopped: %v", fake.stopped)
	}
}

// TestRunVolumeDumps_FailureSurfaces proves no-silent-partial: a per-stack dump failure lands in
// the summary as a FAIL entry, flips allOK, and does NOT abort the remaining stacks.
func TestRunVolumeDumps_FailureSurfaces(t *testing.T) {
	cfg := &config.Config{}
	cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys")

	fake := &volDumpFakeProvider{
		stacks: []StackSummary{{Name: "broken"}, {Name: "healthy"}},
		volumes: map[string][]string{
			"broken":  {"broken_data"},
			"healthy": {"healthy_data"},
		},
	}
	m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0),
		systemDataPath: cfg.Paths.SystemDataPath, stackProvider: fake}
	m.dumpVolumesSafe = func(name string) error {
		if name == "broken" {
			return errTest
		}
		return nil
	}

	summary, dumped, ok := m.runVolumeDumps()
	if ok {
		t.Error("allOK must be false after a dump failure")
	}
	if dumped != 1 {
		t.Errorf("the healthy stack must still be dumped after another's failure (dumped=%d)", dumped)
	}
	if !containsSummaryPrefix(summary, "FAIL broken volumes:") {
		t.Errorf("summary missing FAIL entry: %v", summary)
	}
	// failedSummaryLines feeds the run's returned error — the FAIL entry must survive the filter.
	if failed := failedSummaryLines(summary); len(failed) != 1 {
		t.Errorf("failedSummaryLines = %v, want exactly the broken entry", failed)
	}
}

var errTest = &testErr{}

type testErr struct{}

func (*testErr) Error() string { return "tar exploded" }

func containsSummary(summary []string, want string) bool {
	for _, s := range summary {
		if s == want {
			return true
		}
	}
	return false
}

func containsSummaryPrefix(summary []string, prefix string) bool {
	for _, s := range summary {
		if strings.HasPrefix(s, prefix) {
			return true
		}
	}
	return false
}
