package web

import (
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"regexp"
	"strings"
	"testing"

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

// testPageServer builds a Server complete enough to render full pages through ServeHTTP
// (templates loaded, settings + stack manager real, agent/hub absent).
func testPageServer(t *testing.T) *Server {
	t.Helper()
	lg := log.New(io.Discard, "", 0)
	dir := t.TempDir()
	cfg := &config.Config{}
	cfg.Customer.ID = "test-customer"
	cfg.Customer.Name = "Teszt Ügyfél"
	cfg.Customer.Domain = "example.hu"
	cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
	cfg.Paths.DataDir = filepath.Join(dir, "data")
	cfg.Stacks.ComposeCommand = "docker compose" // skip detection (not needed for page renders)

	sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
	if err != nil {
		t.Fatalf("settings: %v", err)
	}
	mgr, err := stacks.NewManager(cfg, lg)
	if err != nil {
		t.Fatalf("stacks manager: %v", err)
	}
	s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
	s.loadTemplates()
	return s
}

func getPage(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
	t.Helper()
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, path, nil)
	s.ServeHTTP(rec, req)
	return rec
}

// TestSettingsSplitPagesRender (D1 Scenario A): each page responds 200, carries its own
// sections, and does NOT carry another page's sections (no cross-leak).
func TestSettingsSplitPagesRender(t *testing.T) {
	s := testPageServer(t)
	cases := []struct {
		path    string
		must    []string
		mustNot []string
	}{
		{"/settings",
			[]string{"Rendszer konfiguráció", "Verzió és frissítés", "Vezérlő újraindítása", "Kiszolgáló újraindítása"},
			[]string{"Adattárolók", "Jelszó módosítás", "Értesítési szünet"}},
		{"/settings/notifications",
			[]string{"Beállítások — Értesítések"},
			[]string{"Rendszer konfiguráció", "Adattárolók", "Jelszó módosítás"}},
		{"/settings/security",
			[]string{"Jelszó módosítás", "Földrajzi korlátozás"},
			[]string{"Rendszer konfiguráció", "Adattárolók", "Értesítési szünet"}},
		{"/storage",
			[]string{"Adattárolók", "Tárhely — Meghajtók"},
			[]string{"Rendszer konfiguráció", "Jelszó módosítás", "Értesítési szünet", "NAS-megosztások"}},
		{"/storage/network",
			[]string{"Tárhely — Hálózati tárhely (NAS)", "NAS-megosztások"},
			[]string{"Adattárolók", "Rendszer konfiguráció", "Jelszó módosítás"}},
	}
	for _, c := range cases {
		rec := getPage(t, s, c.path)
		if rec.Code != 200 {
			t.Errorf("GET %s = %d, want 200", c.path, rec.Code)
			continue
		}
		body := rec.Body.String()
		for _, m := range c.must {
			if !strings.Contains(body, m) {
				t.Errorf("GET %s: missing section %q", c.path, m)
			}
		}
		for _, m := range c.mustNot {
			if strings.Contains(body, m) {
				t.Errorf("GET %s: leaked foreign section %q", c.path, m)
			}
		}
	}
}

// TestSettingsSectionInventory (D1 §10): every h3 section of the pre-split settings.html is
// accounted for in the UNION of the four split templates (one deliberate rename noted).
func TestSettingsSectionInventory(t *testing.T) {
	oldHeadings := []string{
		"Rendszer konfiguráció",
		"Verzió és frissítés",
		"Adattárolók",
		"Hálózati tárhely (NAS)",
		"Földrajzi korlátozás",
		"Jelszó módosítás",
		"Értesítések",
		"Alkalmazás-email",
		"Vészhelyzeti információk", // renamed from the misspelled "Veszhelyzeti informaciok"
		"Vezérlő újraindítása",
		"Kiszolgáló újraindítása",
	}
	var union strings.Builder
	for _, f := range []string{"settings_system.html", "settings_notifications.html", "settings_security.html", "storage.html", "storage_network.html"} {
		b, err := templateFS.ReadFile("templates/" + f)
		if err != nil {
			t.Fatalf("read %s: %v", f, err)
		}
		union.Write(b)
	}
	u := union.String()
	for _, h := range oldHeadings {
		if !strings.Contains(u, h) {
			t.Errorf("old settings section %q missing from the union of the split templates", h)
		}
	}
	if strings.Contains(u, "Veszhelyzeti informaciok") {
		t.Error("the misspelled heading survived the split")
	}
}

// TestWizardRoutesMovedWith301 (D1 Scenario E): old wizard URLs permanently redirect.
func TestWizardRoutesMovedWith301(t *testing.T) {
	s := testPageServer(t)
	cases := map[string]string{
		"/settings/storage/init":   "/storage/init",
		"/settings/storage/attach": "/storage/attach",
	}
	for old, want := range cases {
		rec := getPage(t, s, old)
		if rec.Code != http.StatusMovedPermanently {
			t.Errorf("GET %s = %d, want 301", old, rec.Code)
		}
		if loc := rec.Header().Get("Location"); loc != want {
			t.Errorf("GET %s Location = %q, want %q", old, loc, want)
		}
	}
	// and the new URLs render
	for _, p := range []string{"/storage/init", "/storage/attach"} {
		if rec := getPage(t, s, p); rec.Code != 200 {
			t.Errorf("GET %s = %d, want 200", p, rec.Code)
		}
	}
}

// TestStorageActionRedirectsToStorage (D1 Scenario D): storage POST successes land on
// /storage?storage_msg=..., and the flash renders there.
func TestStorageActionRedirectsToStorage(t *testing.T) {
	s := testPageServer(t)
	if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/test-drive", Label: "Teszt", Schedulable: true}); err != nil {
		t.Fatalf("add path: %v", err)
	}

	form := url.Values{"storage_path": {"/mnt/test-drive"}, "storage_label": {"Új Név"}}
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/settings/storage/label", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	s.settingsStorageLabelHandler(rec, req)

	if rec.Code != http.StatusFound {
		t.Fatalf("label POST = %d, want 302", rec.Code)
	}
	loc := rec.Header().Get("Location")
	if !strings.HasPrefix(loc, "/storage?storage_msg=success") {
		t.Errorf("Location = %q, want prefix /storage?storage_msg=success", loc)
	}

	// The flash renders on /storage
	rec2 := getPage(t, s, loc)
	if rec2.Code != 200 {
		t.Fatalf("GET %s = %d, want 200", loc, rec2.Code)
	}
	if !strings.Contains(rec2.Body.String(), "Megnevezés módosítva") {
		t.Errorf("/storage did not render the storage flash message")
	}
}

// TestPasswordErrorRerendersSecurityPage (D1 Scenario D): a wrong current password
// re-renders the page carrying the password form with the inline error.
func TestPasswordErrorRerendersSecurityPage(t *testing.T) {
	s := testPageServer(t)
	// enable auth so the password form path is active
	if err := s.settings.SetPasswordHash("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"); err != nil { // "password"
		t.Fatalf("set hash: %v", err)
	}
	form := url.Values{
		"current_password": {"wrong-password"},
		"new_password":     {"newpassword123"},
		"confirm_password": {"newpassword123"},
	}
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/settings/password", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	s.settingsPasswordHandler(rec, req)

	if rec.Code != 200 {
		t.Fatalf("password POST = %d, want 200 (inline error re-render)", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Hibás jelenlegi jelszó") {
		t.Error("missing inline error 'Hibás jelenlegi jelszó'")
	}
	if !strings.Contains(body, "Jelszó módosítás") {
		t.Error("re-rendered page lacks the password form section")
	}
}

// TestStorageNoNativeConfirm (D1 Scenario F): the four split templates contain no native
// confirm()/prompt() — all consequential actions route through the overlay.
func TestStorageNoNativeConfirm(t *testing.T) {
	nativeRe := regexp.MustCompile(`(^|[^A-Za-z_.])(confirm|prompt)\(`)
	// allow the pre-existing type-to-confirm overlay helper names
	allow := regexp.MustCompile(`openConfirm|__closeConfirm|confirmEject|confirmWipe|typeToConfirm|confirm-`)
	for _, f := range []string{"storage.html", "storage_network.html", "settings_system.html", "settings_notifications.html", "settings_security.html"} {
		b, err := templateFS.ReadFile("templates/" + f)
		if err != nil {
			t.Fatalf("read %s: %v", f, err)
		}
		for i, line := range strings.Split(string(b), "\n") {
			if nativeRe.MatchString(line) && !allow.MatchString(line) {
				t.Errorf("%s:%d native confirm/prompt: %s", f, i+1, strings.TrimSpace(line))
			}
		}
	}
}

// TestStorageAgentDownNote (D1 Scenario C, static): the enrichment JS has an error path that
// renders the exact warn note into #agent-warn-note (graceful degradation when the agent is down).
func TestStorageAgentDownNote(t *testing.T) {
	b, err := templateFS.ReadFile("templates/storage.html")
	if err != nil {
		t.Fatal(err)
	}
	body := string(b)
	if !strings.Contains(body, `id="agent-warn-note"`) {
		t.Error("missing #agent-warn-note element")
	}
	if !strings.Contains(body, "Az ügynök nem elérhető") {
		t.Error("missing the agent-unreachable warn text")
	}
	// the catch block must target the note element
	if !strings.Contains(body, "warn.innerHTML=") {
		t.Error("enrichment JS lacks the warn-note error path")
	}
}

// TestNoEmojiInTemplates (D1 §10, Group G): no emoji/pictographs in web templates. Go-side
// codepoint scan (the D0 grep-based gate false-negatived multibyte emoji on Windows).
func TestNoEmojiInTemplates(t *testing.T) {
	allow := map[rune]bool{}
	for _, r := range "✓✗✔✘•●○■▶" {
		allow[r] = true
	}
	isEmoji := func(r rune) bool {
		if allow[r] {
			return false
		}
		switch {
		case r >= 0x1F300 && r <= 0x1FAFF,
			r >= 0x2600 && r <= 0x26FF,
			r >= 0x2700 && r <= 0x27BF,
			r >= 0xFE00 && r <= 0xFE0F,
			r >= 0x1F1E6 && r <= 0x1F1FF:
			return true
		}
		return false
	}
	entries, _ := templateFS.ReadDir("templates")
	for _, e := range entries {
		if !strings.HasSuffix(e.Name(), ".html") {
			continue
		}
		b, err := templateFS.ReadFile("templates/" + e.Name())
		if err != nil {
			t.Fatal(err)
		}
		for _, r := range string(b) {
			if isEmoji(r) {
				t.Errorf("%s contains emoji %q (U+%04X)", e.Name(), r, r)
			}
		}
	}
}

// TestListRemovalHiddenForEnrolledDrives: "Eltávolítás a listából" is the undo of a MANUAL
// add only — a wizard-enrolled drive (/mnt/felhom-drives/) must not offer it (its lifecycle
// is disconnect/decommission), while a manually added path keeps it.
func TestListRemovalHiddenForEnrolledDrives(t *testing.T) {
	s := testPageServer(t)
	// two paths so neither is "the last one"; neither is default so the button condition can fire
	if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/enrolled1", Label: "Enrolled", Schedulable: true, IsDefault: true}); err != nil {
		t.Fatal(err)
	}
	if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/manual_hdd", Label: "Manual", Schedulable: true}); err != nil {
		t.Fatal(err)
	}

	body := getPage(t, s, "/storage").Body.String()

	// the manual card renders the list-removal form, the enrolled card must not
	manualForm := `value="/mnt/manual_hdd">`
	enrolledForm := `value="/mnt/felhom-drives/enrolled1">`
	removeAction := `action="/settings/storage/remove"`
	if !strings.Contains(body, removeAction) {
		t.Fatal("no list-removal form rendered at all — manual path should have one")
	}
	// slice per remove-form occurrence and check which path each carries
	var enrolledHasRemove, manualHasRemove bool
	for _, chunk := range strings.Split(body, removeAction)[1:] {
		head := chunk
		if len(head) > 400 {
			head = head[:400]
		}
		if strings.Contains(head, enrolledForm) {
			enrolledHasRemove = true
		}
		if strings.Contains(head, manualForm) {
			manualHasRemove = true
		}
	}
	if enrolledHasRemove {
		t.Error("enrolled drive (/mnt/felhom-drives/) offers 'Eltávolítás a listából' — must be hidden")
	}
	if !manualHasRemove {
		t.Error("manually added path lost its 'Eltávolítás a listából' button")
	}
}
