package api

import (
	"crypto/sha256"
	"encoding/hex"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/ponzischeme89/memby/server/internal/appupdate"
	"github.com/ponzischeme89/memby/server/internal/config"
	"github.com/ponzischeme89/memby/server/internal/emby"
)

func TestReleasePublishAuth(t *testing.T) {
	handler := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }

	t.Run("disabled is hidden", func(t *testing.T) {
		s := &Server{cfg: config.Config{}}
		rec := httptest.NewRecorder()
		s.releasePublishAuth(handler).ServeHTTP(
			rec,
			httptest.NewRequest(http.MethodPost, "/admin/api/release", nil),
		)
		if rec.Code != http.StatusNotFound {
			t.Fatalf("got %d, want 404", rec.Code)
		}
	})

	t.Run("valid bearer token is accepted", func(t *testing.T) {
		s := &Server{cfg: config.Config{ReleasePublishToken: "release-secret"}}
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/admin/api/release", nil)
		req.Header.Set("Authorization", "Bearer release-secret")
		s.releasePublishAuth(handler).ServeHTTP(rec, req)
		if rec.Code != http.StatusNoContent {
			t.Fatalf("got %d, want 204", rec.Code)
		}
	})
}

func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
	dir := t.TempDir()
	payload := []byte("PK signed apk")
	if err := os.WriteFile(filepath.Join(dir, "memby-0.1.54.apk"), payload, 0o600); err != nil {
		t.Fatal(err)
	}
	s := &Server{cfg: config.Config{
		ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
	}}

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet,
		s.signedReleasePath("memby-0.1.54.apk"), nil)
	req.SetPathValue("filename", "memby-0.1.54.apk")
	s.handleReleaseDownload(rec, req)
	if rec.Code != http.StatusOK || rec.Body.String() != string(payload) {
		t.Fatalf("valid release response = %d %q", rec.Code, rec.Body.String())
	}
	if disposition := rec.Header().Get("Content-Disposition"); disposition !=
		`attachment; filename="memby-0.1.54.apk"` {
		t.Fatalf("Content-Disposition = %q", disposition)
	}
	if !strings.Contains(rec.Header().Get("X-Robots-Tag"), "noindex") {
		t.Fatalf("APK crawler policy = %q", rec.Header().Get("X-Robots-Tag"))
	}

	rec = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/updates/../secrets", nil)
	req.SetPathValue("filename", "../secrets")
	s.handleReleaseDownload(rec, req)
	if rec.Code != http.StatusNotFound {
		t.Fatalf("invalid filename got %d, want 404", rec.Code)
	}
}

func TestInstallPageAndLatestDownloadUsePublishedRelease(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "memby-0.1.72.apk"), []byte("apk"), 0o600); err != nil {
		t.Fatal(err)
	}
	s := &Server{cfg: config.Config{
		ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
	}}
	s.updatePolicy.set(appupdate.Policy{
		LatestVersion: "0.1.72",
		Notes:         `<script>alert("no")</script>`,
	})

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/install", nil)
	addInstallerSession(t, s, req)
	s.handleInstallPage(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("install page status = %d", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Download Memby 0.1.72") ||
		!strings.Contains(body, `href="/updates/latest.apk"`) {
		t.Fatalf("install page is missing current release details: %s", body)
	}
	if strings.Contains(body, `<script>alert`) {
		t.Fatal("release notes were rendered without HTML escaping")
	}
	if rec.Header().Get("Cache-Control") != "no-store" {
		t.Fatalf("install page cache policy = %q", rec.Header().Get("Cache-Control"))
	}
	if robots := rec.Header().Get("X-Robots-Tag"); !strings.Contains(robots, "noindex") ||
		!strings.Contains(robots, "nofollow") {
		t.Fatalf("install page crawler policy = %q", robots)
	}
	if !strings.Contains(body, `name="robots" content="noindex,nofollow`) {
		t.Fatal("install page has no crawler metadata")
	}

	rec = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/updates/latest.apk", nil)
	addInstallerSession(t, s, req)
	s.handleLatestReleaseDownload(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("latest APK status = %d", rec.Code)
	}
	if location := rec.Header().Get("Location"); location != "" {
		t.Fatalf("latest APK unexpectedly redirected to %q", location)
	}
	if contentType := rec.Header().Get("Content-Type"); contentType != "application/vnd.android.package-archive" {
		t.Fatalf("latest APK content type = %q", contentType)
	}
	if disposition := rec.Header().Get("Content-Disposition"); disposition != `attachment; filename="memby-0.1.72.apk"` {
		t.Fatalf("latest APK disposition = %q", disposition)
	}
	if rec.Body.String() != "apk" {
		t.Fatalf("latest APK body = %q", rec.Body.String())
	}
}

func TestRobotsDisallowsEntireHost(t *testing.T) {
	rec := httptest.NewRecorder()
	handleRobots(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))

	if rec.Code != http.StatusOK {
		t.Fatalf("robots status = %d", rec.Code)
	}
	if got := rec.Body.String(); got != "User-agent: *\nDisallow: /\n" {
		t.Fatalf("robots body = %q", got)
	}
	if !strings.Contains(rec.Header().Get("X-Robots-Tag"), "noindex") {
		t.Fatalf("robots response crawler policy = %q", rec.Header().Get("X-Robots-Tag"))
	}
}

func TestInstallPageIsUnavailableWithoutPublishedAPK(t *testing.T) {
	s := &Server{cfg: config.Config{
		ReleaseDir: t.TempDir(), ReleasePublishToken: "test-release-secret",
	}}
	s.updatePolicy.set(appupdate.Policy{LatestVersion: "0.1.72"})

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/install", nil)
	addInstallerSession(t, s, req)
	s.handleInstallPage(rec, req)
	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("install page status = %d, want 503", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "installer is not available yet") {
		t.Fatalf("unavailable page gave no useful message: %s", rec.Body.String())
	}
}

func TestInstallerHidesReleaseUntilEmbyAuthenticated(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "memby-0.1.72.apk"), []byte("apk"), 0o600); err != nil {
		t.Fatal(err)
	}
	s := &Server{cfg: config.Config{
		ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
	}}
	s.updatePolicy.set(appupdate.Policy{LatestVersion: "0.1.72"})

	rec := httptest.NewRecorder()
	s.handleInstallPage(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("login page status = %d", rec.Code)
	}
	body := rec.Body.String()
	lowerBody := strings.ToLower(body)
	if !strings.Contains(body, "Username") || !strings.Contains(body, "Password") ||
		strings.Contains(lowerBody, "memby") || strings.Contains(lowerBody, "emby") ||
		strings.Contains(lowerBody, "installer") || strings.Contains(body, "0.1.72") {
		t.Fatalf("unauthenticated page leaked release access: %s", body)
	}

	rec = httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/updates/latest.apk", nil)
	s.handleLatestReleaseDownload(rec, req)
	if rec.Code != http.StatusNotFound {
		t.Fatalf("unauthenticated latest APK status = %d, want 404", rec.Code)
	}

	rec = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.72.apk", nil)
	req.SetPathValue("filename", "memby-0.1.72.apk")
	s.handleReleaseDownload(rec, req)
	if rec.Code != http.StatusNotFound {
		t.Fatalf("unsigned APK URL status = %d, want 404", rec.Code)
	}
}

func TestInstallerIsHiddenWhenReleaseSecretIsUnconfigured(t *testing.T) {
	s := &Server{cfg: config.Config{ReleaseDir: t.TempDir()}}
	rec := httptest.NewRecorder()
	s.handleInstallPage(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
	if rec.Code != http.StatusNotFound {
		t.Fatalf("unconfigured installer status = %d, want 404", rec.Code)
	}
}

func TestInstallerLoginDoesNotAuthenticateWithoutDeviceCleanupCredential(t *testing.T) {
	s := &Server{
		cfg: config.Config{ReleasePublishToken: "test-release-secret"},
		log: slog.New(slog.NewTextHandler(io.Discard, nil)),
	}
	form := url.Values{"username": {"Matt"}, "password": {"password"}}
	req := httptest.NewRequest(http.MethodPost, "/install/login",
		strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()

	s.handleInstallLogin(rec, req)

	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("installer login status = %d, want 503", rec.Code)
	}
	if len(rec.Result().Cookies()) != 0 {
		t.Fatal("installer login issued a session without a device cleanup credential")
	}
}

func TestInstallerLoginUsesEmbyWithoutCreatingTVSession(t *testing.T) {
	var logoutCalls atomic.Int32
	var deleteCalls atomic.Int32
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/Users/AuthenticateByName":
			w.Header().Set("Content-Type", "application/json")
			_, _ = io.WriteString(w, `{
				"User":{"Id":"emby-user","Name":"Matt"},
				"AccessToken":"temporary-emby-token",
				"ServerId":"emby-server"
			}`)
		case "/Sessions/Logout":
			logoutCalls.Add(1)
			w.WriteHeader(http.StatusNoContent)
		case "/Devices":
			if r.Method == http.MethodGet {
				_, _ = io.WriteString(w, `{"Items":[
					{"Id":"keep","ReportedDeviceId":"living-room-tv"},
					{"Id":"remove","ReportedDeviceId":"memby-web-installer"}
				]}`)
			} else if r.Method == http.MethodDelete && r.URL.Query().Get("Id") == "remove" {
				deleteCalls.Add(1)
				w.WriteHeader(http.StatusNoContent)
			} else {
				http.Error(w, "unexpected device request", http.StatusBadRequest)
			}
		default:
			http.NotFound(w, r)
		}
	}))
	defer upstream.Close()

	s := &Server{
		cfg: config.Config{
			ReleasePublishToken: "test-release-secret",
			SyncUserID:          "service-user", SyncAPIKey: "service-token",
		},
		emby: emby.New(upstream.URL, upstream.URL, "Memby test", 2*time.Second),
		log:  slog.New(slog.NewTextHandler(io.Discard, nil)),
	}
	form := url.Values{
		"username": {"Matt"}, "password": {"correct horse"}, "next": {"/admin/"},
	}
	req := httptest.NewRequest(http.MethodPost, "/install/login",
		strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()

	s.handleInstallLogin(rec, req)

	if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
		t.Fatalf("login response = %d, location %q", rec.Code, rec.Header().Get("Location"))
	}
	if logoutCalls.Load() != 1 {
		t.Fatalf("Emby logout calls = %d, want 1", logoutCalls.Load())
	}
	if deleteCalls.Load() != 1 {
		t.Fatalf("Emby device delete calls = %d, want 1", deleteCalls.Load())
	}
	cookies := rec.Result().Cookies()
	if len(cookies) != 1 || cookies[0].Name != installerCookieName ||
		!cookies[0].HttpOnly || !cookies[0].Secure ||
		cookies[0].SameSite != http.SameSiteStrictMode {
		t.Fatalf("installer cookie is not hardened: %+v", cookies)
	}
	follow := httptest.NewRequest(http.MethodGet, "/install", nil)
	follow.AddCookie(cookies[0])
	if !s.validInstallerSession(follow) {
		t.Fatal("issued installer session was not accepted")
	}
}

func addInstallerSession(t *testing.T, s *Server, req *http.Request) {
	t.Helper()
	value, err := s.newInstallerSession()
	if err != nil {
		t.Fatal(err)
	}
	req.AddCookie(&http.Cookie{Name: installerCookieName, Value: value})
}

func TestFileSHA256(t *testing.T) {
	path := filepath.Join(t.TempDir(), "release.apk")
	payload := []byte("a complete release payload")
	if err := os.WriteFile(path, payload, 0o600); err != nil {
		t.Fatal(err)
	}
	sum := sha256.Sum256(payload)
	want := hex.EncodeToString(sum[:])
	got, err := fileSHA256(path)
	if err != nil {
		t.Fatal(err)
	}
	if got != want {
		t.Fatalf("fileSHA256() = %q, want %q", got, want)
	}
}

func TestParseMandatoryRelease(t *testing.T) {
	for _, test := range []struct {
		value     string
		mandatory bool
		valid     bool
	}{
		{"", false, true},
		{"0", false, true},
		{"false", false, true},
		{"1", true, true},
		{"TRUE", true, true},
		{"sometimes", false, false},
	} {
		mandatory, valid := parseMandatoryRelease(test.value)
		if mandatory != test.mandatory || valid != test.valid {
			t.Errorf("parseMandatoryRelease(%q) = (%v, %v), want (%v, %v)",
				test.value, mandatory, valid, test.mandatory, test.valid)
		}
	}
}
