package selfupdate

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"

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

// newRegistryUpdater builds an updater pointed at a fake registry with the given creds.
func newRegistryUpdater(t *testing.T, baseURL, user, token string) *Updater {
	t.Helper()
	cfg := &config.SelfUpdateConfig{Enabled: true, Image: imageBase}
	git := &config.GitConfig{Username: user, Token: token}
	u := NewUpdater(cfg, git, "0.111.0", t.TempDir(), nil, log.New(io.Discard, "", 0), false)
	u.registryBase = baseURL
	return u
}

// fakeRegistry is an httptest server speaking the Docker Registry v2 anonymous flow.
type fakeRegistry struct {
	*httptest.Server
	mu             sync.Mutex
	tokenHits      int
	tokenHadAuth   bool
	tokenQuery     string
	tagsBasicAuths []string // Authorization headers seen on tags/list
	denyToken      bool     // token endpoint refuses (private registry)
	denyBearerTags bool     // tags/list refuses even WITH the bearer
}

func newFakeRegistry(t *testing.T) *fakeRegistry {
	t.Helper()
	f := &fakeRegistry{}
	mux := http.NewServeMux()
	mux.HandleFunc("/v2/token", func(w http.ResponseWriter, r *http.Request) {
		f.mu.Lock()
		f.tokenHits++
		f.tokenHadAuth = r.Header.Get("Authorization") != ""
		f.tokenQuery = r.URL.RawQuery
		deny := f.denyToken
		f.mu.Unlock()
		if deny {
			w.WriteHeader(401)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"token":"anon-token-123"}`)
	})
	mux.HandleFunc("/v2/admin/felhom-controller/tags/list", func(w http.ResponseWriter, r *http.Request) {
		auth := r.Header.Get("Authorization")
		f.mu.Lock()
		f.tagsBasicAuths = append(f.tagsBasicAuths, auth)
		denyBearer := f.denyBearerTags
		f.mu.Unlock()
		switch {
		case strings.HasPrefix(auth, "Basic "):
			// authenticated path — serve directly
		case auth == "Bearer anon-token-123" && !denyBearer:
			// anonymous path with the issued token — serve
		default:
			w.Header().Set("WWW-Authenticate",
				fmt.Sprintf(`Bearer realm="%s/v2/token",service="container_registry"`, f.Server.URL))
			w.WriteHeader(401)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"name":"admin/felhom-controller","tags":["latest","dev","0.111.0","0.112.0","0.99.0"]}`)
	})
	f.Server = httptest.NewServer(mux)
	t.Cleanup(f.Server.Close)
	return f
}

// Part 1 — the anonymous token dance end-to-end: 401 + WWW-Authenticate → token with NO
// auth → tags with the Bearer → highest semver, with ZERO credentials configured.
// Red-proof: restoring the old creds-required guard in queryRegistry fails this with
// "registry hitelesítő adatok hiányoznak".
func TestQueryRegistry_AnonymousTokenDance(t *testing.T) {
	reg := newFakeRegistry(t)
	u := newRegistryUpdater(t, reg.URL, "", "")

	got, err := u.queryRegistry()
	if err != nil {
		t.Fatalf("anonymous queryRegistry failed: %v", err)
	}
	if got != "0.112.0" {
		t.Fatalf("latest = %q, want 0.112.0 (highest semver, non-semver tags skipped)", got)
	}
	reg.mu.Lock()
	defer reg.mu.Unlock()
	if reg.tokenHits != 1 {
		t.Fatalf("token endpoint hit %d times, want 1", reg.tokenHits)
	}
	if reg.tokenHadAuth {
		t.Fatalf("anonymous token request carried an Authorization header — must be credential-free")
	}
	if !strings.Contains(reg.tokenQuery, "service=container_registry") ||
		!strings.Contains(reg.tokenQuery, "scope=repository%3Aadmin%2Ffelhom-controller%3Apull") {
		t.Fatalf("token query missing service/scope: %q", reg.tokenQuery)
	}
}

// Creds-present path: BasicAuth request shape unchanged, and NO token dance attempted.
func TestQueryRegistry_CredsPath_Unchanged(t *testing.T) {
	reg := newFakeRegistry(t)
	u := newRegistryUpdater(t, reg.URL, "user1", "tok1")

	got, err := u.queryRegistry()
	if err != nil {
		t.Fatalf("creds queryRegistry failed: %v", err)
	}
	if got != "0.112.0" {
		t.Fatalf("latest = %q, want 0.112.0", got)
	}
	reg.mu.Lock()
	defer reg.mu.Unlock()
	if reg.tokenHits != 0 {
		t.Fatalf("creds path attempted the anonymous token dance (%d token hits)", reg.tokenHits)
	}
	if len(reg.tagsBasicAuths) != 1 || !strings.HasPrefix(reg.tagsBasicAuths[0], "Basic ") {
		t.Fatalf("tags/list not requested with BasicAuth: %v", reg.tagsBasicAuths)
	}
}

// Denied-anonymous (token endpoint refuses): the NEW clear error — not a crash, not the
// old "credentials missing" message.
func TestQueryRegistry_AnonymousDenied_TokenEndpoint(t *testing.T) {
	reg := newFakeRegistry(t)
	reg.denyToken = true
	u := newRegistryUpdater(t, reg.URL, "", "")

	_, err := u.queryRegistry()
	if err == nil {
		t.Fatal("expected error from a denying registry")
	}
	if !strings.Contains(err.Error(), "denied anonymous access") ||
		!strings.Contains(err.Error(), "Git Sync credentials") {
		t.Fatalf("wrong denial error: %v", err)
	}
	if strings.Contains(err.Error(), "hiányoznak") {
		t.Fatalf("the OLD credentials-missing message leaked back: %v", err)
	}
}

// Denied-anonymous (tags 401 even WITH the Bearer): same clear error.
func TestQueryRegistry_AnonymousDenied_TagsWithBearer(t *testing.T) {
	reg := newFakeRegistry(t)
	reg.denyBearerTags = true
	u := newRegistryUpdater(t, reg.URL, "", "")

	_, err := u.queryRegistry()
	if err == nil || !strings.Contains(err.Error(), "denied anonymous access") {
		t.Fatalf("want the anonymous-denial error, got: %v", err)
	}
}

// Half-configured credentials stay a loud misconfig, never silently anonymous.
func TestQueryRegistry_PartialCreds_LoudError(t *testing.T) {
	reg := newFakeRegistry(t)
	for _, pair := range [][2]string{{"user-only", ""}, {"", "token-only"}} {
		u := newRegistryUpdater(t, reg.URL, pair[0], pair[1])
		_, err := u.queryRegistry()
		if err == nil || !strings.Contains(err.Error(), "hiányos registry hitelesítő adatok") {
			t.Fatalf("partial creds (%q,%q): want the incomplete-credentials error, got %v", pair[0], pair[1], err)
		}
	}
}

// realm/service parsing: quoted + bare values, order independence, commas inside quotes,
// and clear errors on unusable headers.
func TestParseWWWAuthenticate(t *testing.T) {
	cases := []struct {
		in            string
		realm, svc    string
		wantErrSubstr string
	}{
		{in: `Bearer realm="https://r.example/v2/token",service="container_registry"`,
			realm: "https://r.example/v2/token", svc: "container_registry"},
		{in: `bearer service=svc,realm=https://r.example/tok`, // bare values, swapped order, lowercase scheme word
			realm: "https://r.example/tok", svc: "svc"},
		{in: `Bearer realm="https://r.example/t",service="s",error="insufficient_scope, retry"`,
			realm: "https://r.example/t", svc: "s"}, // comma INSIDE a quoted param must not split
		{in: `Bearer service="s"`, wantErrSubstr: "no realm"},
		{in: `Basic realm="x"`, wantErrSubstr: "not a Bearer"},
		{in: ``, wantErrSubstr: "empty"},
	}
	for _, c := range cases {
		realm, svc, err := parseWWWAuthenticate(c.in)
		if c.wantErrSubstr != "" {
			if err == nil || !strings.Contains(err.Error(), c.wantErrSubstr) {
				t.Errorf("parse(%q): want error containing %q, got %v", c.in, c.wantErrSubstr, err)
			}
			continue
		}
		if err != nil {
			t.Errorf("parse(%q): unexpected error %v", c.in, err)
			continue
		}
		if realm != c.realm || svc != c.svc {
			t.Errorf("parse(%q) = (%q,%q), want (%q,%q)", c.in, realm, svc, c.realm, c.svc)
		}
	}
}

// fakeRunner records docker CLI invocations for pullImage tests.
type fakeRunner struct {
	mu       sync.Mutex
	commands []string // "login"/"pull"/"logout"/...
	stdins   []string
}

func (f *fakeRunner) install(t *testing.T) {
	t.Helper()
	origRun, origRunStdin := runCommand, runCommandStdin
	runCommand = func(name string, args ...string) (string, error) {
		f.mu.Lock()
		defer f.mu.Unlock()
		f.commands = append(f.commands, strings.Join(append([]string{name}, args...), " "))
		return "", nil
	}
	runCommandStdin = func(stdin, name string, args ...string) (string, error) {
		f.mu.Lock()
		defer f.mu.Unlock()
		f.commands = append(f.commands, strings.Join(append([]string{name}, args...), " "))
		f.stdins = append(f.stdins, stdin)
		return "", nil
	}
	t.Cleanup(func() { runCommand, runCommandStdin = origRun, origRunStdin })
}

// Part 2 — no creds → NO docker login, the pull still runs (docker's native anonymous flow).
func TestPullImage_NoCreds_SkipsLogin(t *testing.T) {
	fr := &fakeRunner{}
	fr.install(t)
	u := newRegistryUpdater(t, "http://unused", "", "")

	if err := u.pullImage(imageBase + ":0.112.0"); err != nil {
		t.Fatalf("anonymous pullImage failed: %v", err)
	}
	fr.mu.Lock()
	defer fr.mu.Unlock()
	joined := strings.Join(fr.commands, " | ")
	if strings.Contains(joined, "login") || strings.Contains(joined, "logout") {
		t.Fatalf("anonymous pull invoked docker login/logout: %v", fr.commands)
	}
	if len(fr.commands) != 1 || fr.commands[0] != "docker pull "+imageBase+":0.112.0" {
		t.Fatalf("pull not invoked exactly once: %v", fr.commands)
	}
}

// Creds present → login (token via stdin) → pull → logout, unchanged.
func TestPullImage_WithCreds_LoginPullLogout(t *testing.T) {
	fr := &fakeRunner{}
	fr.install(t)
	u := newRegistryUpdater(t, "http://unused", "user1", "tok1")

	if err := u.pullImage(imageBase + ":0.112.0"); err != nil {
		t.Fatalf("authenticated pullImage failed: %v", err)
	}
	fr.mu.Lock()
	defer fr.mu.Unlock()
	want := []string{
		"docker login gitea.dooplex.hu -u user1 --password-stdin",
		"docker pull " + imageBase + ":0.112.0",
		"docker logout gitea.dooplex.hu",
	}
	if len(fr.commands) != 3 {
		t.Fatalf("commands = %v, want login/pull/logout", fr.commands)
	}
	for i := range want {
		if fr.commands[i] != want[i] {
			t.Fatalf("command[%d] = %q, want %q", i, fr.commands[i], want[i])
		}
	}
	if len(fr.stdins) != 1 || fr.stdins[0] != "tok1" {
		t.Fatalf("token not passed via stdin: %v", fr.stdins)
	}
}

// Half-configured creds refuse the pull loudly (never a silent anonymous downgrade).
func TestPullImage_PartialCreds_LoudError(t *testing.T) {
	fr := &fakeRunner{}
	fr.install(t)
	u := newRegistryUpdater(t, "http://unused", "user-only", "")

	err := u.pullImage(imageBase + ":0.112.0")
	if err == nil || !strings.Contains(err.Error(), "hiányos registry hitelesítő adatok") {
		t.Fatalf("want incomplete-credentials error, got %v", err)
	}
	fr.mu.Lock()
	defer fr.mu.Unlock()
	if len(fr.commands) != 0 {
		t.Fatalf("no docker command may run on misconfig: %v", fr.commands)
	}
}

// RegistryAnonymous drives the settings-page mode line; DryRun.PullCapable counts
// anonymous as capable (only a half-configured pair is incapable).
func TestRegistryMode_And_PullCapable(t *testing.T) {
	anon := newRegistryUpdater(t, "http://unused", "", "")
	if !anon.RegistryAnonymous() {
		t.Fatal("empty creds must report anonymous mode")
	}
	authed := newRegistryUpdater(t, "http://unused", "u", "t")
	if authed.RegistryAnonymous() {
		t.Fatal("full creds must report authenticated mode")
	}
	if capable := (anon.gitCfg.Username != "" && anon.gitCfg.Token != "") || anon.RegistryAnonymous(); !capable {
		t.Fatal("anonymous must be pull-capable")
	}
	partial := newRegistryUpdater(t, "http://unused", "u", "")
	if capable := (partial.gitCfg.Username != "" && partial.gitCfg.Token != "") || partial.RegistryAnonymous(); capable {
		t.Fatal("partial creds must NOT be pull-capable")
	}
}
