package web

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

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

// ---------------------------------------------------------------------------
// Pure state-pick helpers (Group C logic + tier3State precedence for Group B)
// ---------------------------------------------------------------------------

// TestDBSectionState — table-driven truth table for the embedded-DB-honest messaging pick.
// COMPANION red-proof: make dbSectionState ignore `discovered` (return "embedded" when dumps==0)
// → the (2,0)="pending" case below FAILS. Run → fail → revert (recorded in REPORT).
func TestDBSectionState(t *testing.T) {
	cases := []struct {
		discovered, dumps int
		want              string
	}{
		{0, 0, "embedded"}, // Scenario C: SQLite-only box — neither dump nor discovery
		{2, 0, "pending"},  // discovered but no dump yet → "first run tonight" (NOT embedded)
		{2, 3, "dumps"},    // real dumps present → show the table
		{0, 3, "dumps"},    // stale dumps from a removed DB app still show (dumps wins)
	}
	for _, c := range cases {
		if got := dbSectionState(c.discovered, c.dumps); got != c.want {
			t.Errorf("dbSectionState(%d,%d) = %q, want %q", c.discovered, c.dumps, got, c.want)
		}
	}
}

// TestTier3State — the per-app off-box row state, with strict configured→toggle→escrow precedence.
func TestTier3State(t *testing.T) {
	cases := []struct {
		name       string
		configured bool
		toggled    bool
		escrow     string
		want       string
	}{
		{"unconfigured wins over stale toggle", false, true, "escrowed", "unconfigured"},
		{"configured but app off", true, false, "escrowed", "off"},
		{"toggled + escrowed → active", true, true, "escrowed", "active"},
		{"toggled + pending escrow → escrow_pending", true, true, "pending", "escrow_pending"},
		{"toggled + empty escrow → escrow_pending", true, true, "", "escrow_pending"},
	}
	for _, c := range cases {
		if got := tier3State(c.configured, c.toggled, c.escrow); got != c.want {
			t.Errorf("%s: tier3State(%v,%v,%q) = %q, want %q", c.name, c.configured, c.toggled, c.escrow, got, c.want)
		}
	}
}

// ---------------------------------------------------------------------------
// Handler wiring: buildAppBackupRows maps OffboxEnabled + Tier3State (Scenario A/B)
// ---------------------------------------------------------------------------

// configureOffbox sets up a real, configured off-box target (secrets + settings) so
// OffboxConfigured() reports true, with the given escrow state.
func configureOffbox(t *testing.T, sett *settings.Settings, m *backup.Manager, escrow string) {
	t.Helper()
	if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
		t.Fatal(err)
	}
	if err := sett.SetOffboxTarget(&settings.OffboxTarget{
		Enabled: true, Host: "u629488-sub1.your-storagebox.de", Port: 22, User: "felhom",
		RepoPath: "/home/repo", Schedule: "daily", EscrowState: escrow,
		LastStatus: "ok", LastRun: "2026-07-11T02:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	if !m.OffboxConfigured() {
		t.Fatal("target should be configured")
	}
}

func findRow(rows []AppBackupRow, stack string) *AppBackupRow {
	for i := range rows {
		if rows[i].StackName == stack {
			return &rows[i]
		}
	}
	return nil
}

// TestBuildAppBackupRows_OffboxMapping (Scenario A + B wiring): the per-app Tier-3 state is
// (global configured) × (global escrow) × (per-app toggle). calibre toggled ON → active; radarr
// OFF → off. COMPANION red-proof: hardcode row.OffboxEnabled=false in buildAppBackupRows → the
// calibre "active"/OffboxEnabled assertions FAIL. Run → fail → revert (recorded in REPORT).
func TestBuildAppBackupRows_OffboxMapping(t *testing.T) {
	s, sett, m := newOffboxWebServer(t)
	configureOffbox(t, sett, m, "escrowed")
	if err := sett.SetAppOffbox("calibre-web", true); err != nil {
		t.Fatal(err)
	}
	status := &backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
		{StackName: "calibre-web", DisplayName: "Calibre-Web"},
		{StackName: "radarr", DisplayName: "Radarr"},
	}}
	rows := s.buildAppBackupRows(status)

	cal := findRow(rows, "calibre-web")
	if cal == nil || !cal.OffboxEnabled || cal.Tier3State != "active" {
		t.Fatalf("calibre-web: OffboxEnabled/Tier3State wrong: %+v", cal)
	}
	rad := findRow(rows, "radarr")
	if rad == nil || rad.OffboxEnabled || rad.Tier3State != "off" {
		t.Fatalf("radarr: OffboxEnabled/Tier3State wrong: %+v", rad)
	}
}

// TestBuildAppBackupRows_EscrowPendingPrecedence (Scenario B): a toggled app while escrow is
// pending is escrow_pending, never active — even though LastStatus is "ok" from a prior run.
func TestBuildAppBackupRows_EscrowPendingPrecedence(t *testing.T) {
	s, sett, m := newOffboxWebServer(t)
	configureOffbox(t, sett, m, "pending")
	if err := sett.SetAppOffbox("calibre-web", true); err != nil {
		t.Fatal(err)
	}
	rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
		{StackName: "calibre-web", DisplayName: "Calibre-Web"},
	}})
	cal := findRow(rows, "calibre-web")
	if cal == nil || cal.Tier3State != "escrow_pending" {
		t.Fatalf("escrow-pending must win over a prior ok run: %+v", cal)
	}
}

// TestBuildAppBackupRows_Tier1FromRestorePoints (Scenario D): a recovery unit on disk → Tier1LastRun
// is its newest-artifact RFC3339 time; an app with no unit → Tier1LastRun stays "" (no fabrication).
// COMPANION red-proof: drop the ListRestorePoints assignment (leave Tier1LastRun unset) → the
// "with-unit" assertion FAILS. Run → fail → revert (recorded in REPORT).
func TestBuildAppBackupRows_Tier1FromRestorePoints(t *testing.T) {
	s, _, m := newOffboxWebServer(t)
	drive := filepath.Join(t.TempDir(), "drive")
	m.SetStackProvider(&unitProvider{blockProvider{hdd: drive}})

	// Write a recovery-unit manifest for "hasunit" with a known, decisively-past mtime.
	manifest := backup.RecoveryUnitManifestPath(drive, "hasunit")
	if err := os.MkdirAll(filepath.Dir(manifest), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(manifest, []byte("{}"), 0o644); err != nil {
		t.Fatal(err)
	}
	mtime := time.Date(2026, 7, 10, 3, 30, 0, 0, time.UTC)
	if err := os.Chtimes(manifest, mtime, mtime); err != nil {
		t.Fatal(err)
	}

	rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
		{StackName: "hasunit", DisplayName: "Has Unit"},
		{StackName: "nounit", DisplayName: "No Unit"},
	}})

	hu := findRow(rows, "hasunit")
	if hu == nil || hu.Tier1LastRun != mtime.Format(time.RFC3339) {
		t.Fatalf("hasunit Tier1LastRun = %q, want %q", huTier1(hu), mtime.Format(time.RFC3339))
	}
	if hu.Tier1LastStatus != "ok" {
		t.Errorf("hasunit Tier1LastStatus = %q, want ok", hu.Tier1LastStatus)
	}
	nu := findRow(rows, "nounit")
	if nu == nil || nu.Tier1LastRun != "" {
		t.Fatalf("nounit must have NO fabricated Tier1LastRun, got %q", huTier1(nu))
	}
}

func huTier1(r *AppBackupRow) string {
	if r == nil {
		return "<nil row>"
	}
	return r.Tier1LastRun
}

// unitProvider makes every stack resolvable (GetStackComposePath ok) with the embedded hdd drive,
// so ListRestorePoints resolves the namespace to that drive.
type unitProvider struct{ blockProvider }

func (u *unitProvider) GetStackComposePath(string) (string, bool) { return "compose", true }

// ---------------------------------------------------------------------------
// Template rendering (Group A/B/C/D): the real "backups" template tree
// ---------------------------------------------------------------------------

func renderBackups(t *testing.T, data map[string]interface{}) string {
	return renderBackupPage(t, "backups", data)
}

// renderBackupPage renders one of the four backups sub-page templates (v0.124.0 IA split)
// through the PRODUCTION template tree.
func renderBackupPage(t *testing.T, page string, data map[string]interface{}) string {
	t.Helper()
	s := testServer(t)
	s.loadTemplates()
	var buf bytes.Buffer
	if err := s.tmpl.ExecuteTemplate(&buf, page, data); err != nil {
		t.Fatalf("render %s: %v", page, err)
	}
	return buf.String()
}

// baseBackupData is the minimal data map that makes the "backups" body render (Backup truthy +
// one AppDataInfo so Section 4/7 appear). Callers overlay AppBackupRows / Offbox / DBSectionState.
func baseBackupData(rows []AppBackupRow) map[string]interface{} {
	return map[string]interface{}{
		"Page": "backups", "Title": "Biztonsági mentés",
		"Backup": &backup.FullBackupStatus{
			AppDataInfo: []backup.AppBackupInfo{{StackName: "calibre-web", DisplayName: "Calibre-Web"}},
		},
		"AppBackupRows":  rows,
		"DBSectionState": "dumps",
	}
}

// TestBackupsTemplate_Tier3Live (Scenario A) — active + off rows render truthfully, and every
// removed marker is gone. COMPANION red-proof: re-insert "hamarosan elérhető" into the tier-3 block
// → the no-"hamarosan" assertion FAILS. Run → fail → revert (recorded in REPORT).
func TestBackupsTemplate_Tier3Live(t *testing.T) {
	data := baseBackupData([]AppBackupRow{
		{StackName: "calibre-web", DisplayName: "Calibre-Web", OffboxEnabled: true, Tier3State: "active"},
		{StackName: "radarr", DisplayName: "Radarr", OffboxEnabled: false, Tier3State: "off"},
	})
	data["Offbox"] = &settings.OffboxTarget{
		Enabled: true, Host: "u629488-sub1.your-storagebox.de", LastStatus: "ok",
		LastRun: "2026-07-11T02:00:00Z", EscrowState: "escrowed",
	}
	data["OffboxConfigured"] = true
	html := renderBackupPage(t, "backups_apps", data)

	for _, want := range []string{
		"Sikeres",                              // active badge (LastStatus ok)
		"restic → u629488-sub1.your-storagebox.de", // the real off-box host
		"Utolsó:",                              // a relative last-run time is shown
		"Kikapcsolva",                          // radarr off row
		"Bekapcsolás",                          // radarr enable link
		`href="/backups/remote#offbox-section"`, // the cross-page anchor jump target (IA split)
	} {
		if !strings.Contains(html, want) {
			t.Errorf("rendered apps page missing %q", want)
		}
	}
	remoteHTML := renderBackupPage(t, "backups_remote", data)
	for _, want := range []string{
		"Távoli mentés (3. mentés)", // the renamed section header
		`id="offbox-section"`,       // the anchor target the apps-page links jump to
	} {
		if !strings.Contains(remoteHTML, want) {
			t.Errorf("rendered remote page missing %q", want)
		}
	}
	for _, banned := range []string{
		"hamarosan",         // the dead placeholder copy
		"Tier2DriveGroups",  // dead template field
		"ResticPassword",    // dead template field
		"restic-pw",         // dead element id
		"toggleTier",        // dead JS fn
		"details-tier",      // dead Részletek markup
		"Részletek",         // the removed card title
		"2026-07-11T02:00:00Z", // raw RFC3339 must never leak (timeAgoStr wraps it)
	} {
		if strings.Contains(html, banned) {
			t.Errorf("rendered page still contains removed/raw marker %q", banned)
		}
	}
}

// TestBackupsTemplate_Tier3EscrowPending (Scenario B) — the escrow-pending row shows the wait
// state and NOT a false success. The single row carries no Tier-2, so "Sikeres" would only come
// from a (wrong) tier-3 active badge.
func TestBackupsTemplate_Tier3EscrowPending(t *testing.T) {
	data := baseBackupData([]AppBackupRow{
		{StackName: "calibre-web", DisplayName: "Calibre-Web", OffboxEnabled: true, Tier3State: "escrow_pending"},
	})
	data["Offbox"] = &settings.OffboxTarget{Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "pending"}
	data["OffboxConfigured"] = true
	html := renderBackupPage(t, "backups_apps", data)

	if !strings.Contains(html, "Kulcsletétre vár") {
		t.Error("escrow-pending row must show 'Kulcsletétre vár'")
	}
	// The active-state marker must be absent — no false success. (Bare "Sikeres" would
	// false-match the "Sikeresen törölve" JS toast, so assert the active contents string.)
	if strings.Contains(html, "Helyreállítási egység, titkosítva") {
		t.Error("escrow-pending must NOT render the active tier-3 row (false success)")
	}
}

// TestBackupsTemplate_DBMessaging (Scenario C) — embedded vs pending messaging in the stat card
// and the Adatbázisok empty state.
func TestBackupsTemplate_DBMessaging(t *testing.T) {
	t.Run("embedded", func(t *testing.T) {
		data := baseBackupData(nil)
		data["DBSectionState"] = "embedded"
		overviewHTML := renderBackups(t, data)
		if !strings.Contains(overviewHTML, "beágyazott DB-k a kötetmentésben") { // stat-card sublabel
			t.Error("embedded overview page missing the stat-card sublabel")
		}
		appsHTML := renderBackupPage(t, "backups_apps", data)
		if !strings.Contains(appsHTML, "beágyazott adatbázist használnak") { // Adatbázisok empty state
			t.Error("embedded apps page missing the Adatbázisok explanation")
		}
		if strings.Contains(appsHTML, "Nem található adatbázis mentés.") {
			t.Error("embedded box must NOT show the old bare 'not found' message")
		}
	})
	t.Run("pending", func(t *testing.T) {
		data := baseBackupData(nil)
		data["DBSectionState"] = "pending"
		html := renderBackupPage(t, "backups_apps", data)
		if !strings.Contains(html, "az első ütemezett mentés éjjel fut le") {
			t.Error("pending page missing the 'first run tonight' message")
		}
		if strings.Contains(html, "beágyazott adatbázist használnak") {
			t.Error("pending box must NOT show the embedded-DB message")
		}
	})
}

// TestBackupsTemplate_Tier2RelativeTime (Scenario D) — a Tier-2 last-run renders as relative time,
// never the raw RFC3339 literal.
func TestBackupsTemplate_Tier2RelativeTime(t *testing.T) {
	data := baseBackupData([]AppBackupRow{{
		StackName: "calibre-web", DisplayName: "Calibre-Web",
		Tier2Configured: true, Tier2Dest: "USB", Tier2Schedule: "Naponta",
		Tier2LastRun: "2026-07-10T03:30:00Z", Tier2LastStatus: "ok", Tier2StatusBadge: "Sikeres",
		Tier3State: "unconfigured",
	}})
	html := renderBackupPage(t, "backups_apps", data)
	// The visible "Utolsó:" label must be a relative time, never the raw RFC3339. (The raw
	// timestamp legitimately survives in the restore confirm() dialog — precise there by design.)
	if strings.Contains(html, "Utolsó: 2026-07-10T03:30:00Z") {
		t.Error("Tier-2 last-run label must render via timeAgoStr, not the raw RFC3339 literal")
	}
	if !strings.Contains(html, "Utolsó:") {
		t.Error("Tier-2 row should still show an 'Utolsó:' label")
	}
}
