package web

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

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

// Indítópult (launcher, v0.163.0). The launcher tile exists ⟺ a "Megnyitás" button would exist:
// subdomain presence is the single openability criterion (buildLauncherApps). These tests drive the
// pure builder (deterministic — no docker) for the filter/sort/exclusion logic, and the production
// launcher template for the rendered tile grammar (hrefs, greyed-stopped, monogram, tile color).

// launcherStacks models Scenario A: two openable apps (env-subdomain + meta-subdomain), one protected
// app with a well-known subdomain, one deployed app with NO subdomain, and the controller itself with
// a subdomain that must still be excluded by name.
func launcherStacks() []stacks.Stack {
	return []stacks.Stack{
		{Name: "worker-app", Deployed: true, State: stacks.StateRunning,
			Meta: stacks.Metadata{Slug: "worker-app", DisplayName: "Worker"}}, // no subdomain → excluded
		{Name: "mealie", Deployed: true, State: stacks.StateRunning,
			Meta: stacks.Metadata{Slug: "mealie", DisplayName: "Mealie", Subdomain: "recept"}},
		{Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning,
			Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless"}}, // subdomain via env map
		{Name: "filebrowser", Protected: true, State: stacks.StateRunning,
			Meta: stacks.Metadata{Slug: "filebrowser", DisplayName: "FileBrowser"}}, // protectedStackSubdomains
		{Name: "felhom-controller", Deployed: true, State: stacks.StateRunning,
			Meta: stacks.Metadata{Slug: "felhom-controller", DisplayName: "Vezérlő", Subdomain: "felhom"}},
	}
}

func launcherSubdomains() map[string]string {
	return map[string]string{
		"mealie":            "recept",
		"paperless-ngx":     "papir",
		"filebrowser":       "files",
		"felhom-controller": "felhom", // present on purpose — must be excluded by name anyway
		// worker-app: deliberately absent
	}
}

// Group A — the builder selects exactly the openable apps, sorted by DisplayName, controller excluded.
// COMPANION red-proof (REPORT): drop the `if !ok || sd == ""` continue in buildLauncherApps →
// worker-app (no subdomain) leaks in and the "worker absent" assertion FAILS.
func TestBuildLauncherApps_SelectionAndOrder(t *testing.T) {
	apps := buildLauncherApps(launcherStacks(), launcherSubdomains())

	var names []string
	for _, a := range apps {
		names = append(names, a.Name)
	}
	// Openable set = mealie, paperless-ngx, filebrowser — sorted by DisplayName:
	// FileBrowser < Mealie < Paperless.
	wantOrder := []string{"filebrowser", "mealie", "paperless-ngx"}
	if strings.Join(names, ",") != strings.Join(wantOrder, ",") {
		t.Fatalf("launcher apps = %v, want %v (alphabetical by DisplayName)", names, wantOrder)
	}
	for _, a := range apps {
		if a.Name == "worker-app" {
			t.Error("worker-app has no subdomain — it must not be launchable")
		}
		if a.Name == "felhom-controller" {
			t.Error("the controller stack must never appear on its own launcher")
		}
	}
	// The subdomain is resolved from the lookup, not the metadata blindly (paperless env=papir).
	for _, a := range apps {
		if a.Name == "paperless-ngx" && a.Subdomain != "papir" {
			t.Errorf("paperless subdomain = %q, want papir (from the lookup)", a.Subdomain)
		}
	}
}

// Group A (render) — each openable tile is an <a> to its public URL with OpenPath and target=_blank.
func TestLauncherTemplate_OpenableTiles(t *testing.T) {
	data := map[string]interface{}{
		"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
		"Apps": []LauncherApp{
			{Name: "mealie", DisplayName: "Mealie", Slug: "mealie", State: stacks.StateRunning, Subdomain: "recept"},
			{Name: "gokapi", DisplayName: "Gokapi", Slug: "gokapi", State: stacks.StateRunning, Subdomain: "fajl", OpenPath: "/admin"},
		},
	}
	html := renderBackupPage(t, "launcher", data)
	if !strings.Contains(html, `href="https://recept.demo-felhom.eu"`) {
		t.Error("mealie tile must link to its public URL")
	}
	if !strings.Contains(html, `href="https://fajl.demo-felhom.eu/admin"`) {
		t.Error("gokapi tile must append OpenPath to its public URL")
	}
	if !strings.Contains(html, `target="_blank"`) || !strings.Contains(html, `rel="noopener"`) {
		t.Error("openable tiles must open in a new tab with rel=noopener")
	}
}

// Group B — a stopped app renders a greyed tile with the state badge and NO href (Scenario B).
// COMPANION red-proof (REPORT): in launcher.html render the <a> branch unconditionally (drop the
// isOperational guard) → the "no href for the stopped tile" assertion FAILS.
func TestLauncherTemplate_StoppedTileGreyedNoLink(t *testing.T) {
	data := map[string]interface{}{
		"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
		"Apps": []LauncherApp{
			{Name: "jellyfin", DisplayName: "Jellyfin", Slug: "jellyfin", State: stacks.StateStopped, Subdomain: "media"},
		},
	}
	html := renderBackupPage(t, "launcher", data)
	if strings.Contains(html, "media.demo-felhom.eu") {
		t.Error("a stopped app must not be a link (no href to its subdomain)")
	}
	if !strings.Contains(html, "launch-cell--off") || !strings.Contains(html, "launch-tile--off") {
		t.Error("a stopped tile must carry the greyed classes")
	}
	if !strings.Contains(html, "Leállítva") {
		t.Error("a stopped tile must show its Hungarian stateLabel badge")
	}
}

// Group C — tile color safety: valid brand passes through, an injection payload becomes a hash HSL
// and never appears verbatim in the rendered HTML.
// COMPANION red-proof (REPORT): make tileColor return `brand` unvalidated → the payload substring
// appears in the rendered style and the "payload absent" assertion FAILS.
func TestLauncherTemplate_TileColorSafety(t *testing.T) {
	const payload = "red;background:url(x)"
	data := map[string]interface{}{
		"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
		"Apps": []LauncherApp{
			{Name: "jelly", DisplayName: "Jelly", Slug: "jelly", State: stacks.StateRunning, Subdomain: "j", BrandColor: "#00A4DC"},
			{Name: "evil", DisplayName: "Evil", Slug: "evil", State: stacks.StateRunning, Subdomain: "e", BrandColor: payload},
		},
	}
	html := renderBackupPage(t, "launcher", data)
	if !strings.Contains(html, "background: #00A4DC") {
		t.Error("a valid brand_color must pass through verbatim as the tile background")
	}
	if strings.Contains(html, payload) {
		t.Errorf("the injection payload must never reach the rendered HTML, got it in:\n%s", html)
	}
	if !strings.Contains(html, "background: hsl(") {
		t.Error("an invalid brand_color must fall back to the deterministic hash HSL")
	}
}

func TestTileColor(t *testing.T) {
	// Valid overrides pass through verbatim.
	for _, ok := range []string{"#fff", "#FFF", "#00A4DC", "#00a4dc"} {
		if got := string(tileColor("slug", ok)); got != ok {
			t.Errorf("tileColor(_, %q) = %q, want passthrough", ok, got)
		}
	}
	// Invalid overrides fall back to a deterministic HSL derived from the slug only.
	for _, bad := range []string{"", "red", "#12", "#1234", "red;background:url(x)", "0083D8"} {
		got := string(tileColor("nextcloud", bad))
		if !strings.HasPrefix(got, "hsl(") {
			t.Errorf("tileColor(_, %q) = %q, want hsl() fallback", bad, got)
		}
	}
	// Determinism: same slug ⇒ same color; different slugs generally differ.
	if tileColor("nextcloud", "") != tileColor("nextcloud", "") {
		t.Error("tileColor must be deterministic for a given slug")
	}
	if tileColor("nextcloud", "") == tileColor("paperless-ngx", "") {
		t.Error("distinct slugs collided (acceptable in theory, but these two must not for the fixture)")
	}
}

func TestInitial(t *testing.T) {
	cases := map[string]string{
		"Óra":       "Ó", // multibyte — must not split the byte
		"nextcloud": "N",
		"7days":     "7",
		"":          "?",
		"árvíztűrő": "Á",
	}
	for in, want := range cases {
		if got := initial(in); got != want {
			t.Errorf("initial(%q) = %q, want %q", in, got, want)
		}
	}
}

// Group E — zero openable apps ⇒ the calm empty state with the /stacks link.
func TestLauncherTemplate_EmptyState(t *testing.T) {
	data := map[string]interface{}{
		"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
		"Apps": []LauncherApp{},
	}
	html := renderBackupPage(t, "launcher", data)
	if !strings.Contains(html, "Még nincs telepített alkalmazás.") {
		t.Error("empty launcher must show the calm empty-state copy")
	}
	if !strings.Contains(html, `href="/stacks"`) {
		t.Error("empty launcher must link to /stacks")
	}
}

// End-to-end wiring — GET /launcher through the real ServeHTTP route with disk-seeded stacks:
// the route is registered, the handler builds tiles, the nav marks Indítópult active, and a
// subdomain-less app never appears. State is not asserted here (docker-dependent); the deterministic
// selection/order/state assertions live in the builder + template tests above.
func TestLauncherRoute_EndToEnd(t *testing.T) {
	s := testPageServer(t)
	stacksDir := s.cfg.Paths.StacksDir

	// openable: meta subdomain present
	writeStack(t, stacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true)
	// not openable: no subdomain anywhere
	writeStack(t, stacksDir, "worker-app", "display_name: Worker\n", true)

	if err := s.stackMgr.ScanStacks(); err != nil {
		t.Logf("ScanStacks (docker status step may fail on a docker-less host): %v", err)
	}
	if _, ok := s.stackMgr.GetStack("recept-app"); !ok {
		t.Fatal("recept-app not discovered by ScanStacks")
	}

	rec := getPage(t, s, "/launcher")
	if rec.Code != 200 {
		t.Fatalf("GET /launcher = %d: %s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Receptek") {
		t.Error("the openable app must appear on the launcher")
	}
	if strings.Contains(body, "Worker") {
		t.Error("a subdomain-less app must not appear on the launcher")
	}
	// Nav: Indítópult present and marked active on this page.
	if !strings.Contains(body, `href="/launcher" class="active"`) {
		t.Error("the Indítópult nav entry must be marked active on /launcher")
	}
}

// writeStack plants a minimal deployable stack dir (.felhom.yml + compose + app.yaml deployed).
func writeStack(t *testing.T, stacksDir, name, meta string, deployed bool) {
	t.Helper()
	dir := filepath.Join(stacksDir, name)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if deployed {
		if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("deployed: true\nenv: {}\n"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
}
