package server

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"io/fs"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"
	"testing/fstest"
	"time"

	"golang.org/x/crypto/bcrypt"

	"vocat/internal/auth"
	"vocat/internal/store"
)

type testApplication struct {
	server *httptest.Server
	client *http.Client
}

func newTestApplication(t *testing.T) testApplication {
	t.Helper()
	database, err := store.Open(context.Background(), ":memory:")
	if err != nil {
		t.Fatalf("store.Open() error = %v", err)
	}
	t.Cleanup(func() {
		_ = database.Close()
	})
	authService, err := auth.New(database, auth.Options{
		SessionTTL: time.Hour,
		BcryptCost: bcrypt.MinCost,
	})
	if err != nil {
		t.Fatal(err)
	}
	if err := authService.EnsureAdmin(context.Background(), "admin", "correct-password"); err != nil {
		t.Fatal(err)
	}
	assets := fstest.MapFS{
		"index.html":    &fstest.MapFile{Data: []byte("<html>SPA shell</html>")},
		"assets/app.js": &fstest.MapFile{Data: []byte("console.log('ok')")},
	}
	handler, err := New(Options{
		Store:               database,
		Auth:                authService,
		Assets:              assets,
		MaxRequestBodyBytes: 4096,
	})
	if err != nil {
		t.Fatal(err)
	}
	httpServer := httptest.NewServer(handler)
	t.Cleanup(httpServer.Close)
	jar, err := cookiejar.New(nil)
	if err != nil {
		t.Fatal(err)
	}
	return testApplication{
		server: httpServer,
		client: &http.Client{Jar: jar},
	}
}

func TestHealthAndSPAFallback(t *testing.T) {
	app := newTestApplication(t)

	response, err := app.client.Get(app.server.URL + "/api/health")
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusOK {
		t.Fatalf("health status = %d", response.StatusCode)
	}
	if response.Header.Get("X-Content-Type-Options") != "nosniff" {
		t.Fatal("security headers not present")
	}
	if response.Header.Get("Access-Control-Allow-Origin") != "" {
		t.Fatal("CORS must not be enabled")
	}

	response, err = app.client.Get(app.server.URL + "/settings/deep/link")
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()
	body, _ := io.ReadAll(response.Body)
	if !bytes.Contains(body, []byte("SPA shell")) {
		t.Fatalf("SPA fallback body = %q", body)
	}

	response, err = app.client.Get(app.server.URL + "/assets/app.js")
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()
	if response.Header.Get("Cache-Control") != "public, max-age=31536000, immutable" {
		t.Fatalf("asset Cache-Control = %q", response.Header.Get("Cache-Control"))
	}
}

func TestLoginSessionCSRFAndLogout(t *testing.T) {
	app := newTestApplication(t)

	loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`)
	response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody)
	if err != nil {
		t.Fatal(err)
	}
	var loginResponse struct {
		Data struct {
			CSRFToken string `json:"csrf_token"`
		} `json:"data"`
	}
	if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if response.StatusCode != http.StatusOK || loginResponse.Data.CSRFToken == "" {
		t.Fatalf("login status = %d, body = %+v", response.StatusCode, loginResponse)
	}
	var sessionCookie *http.Cookie
	for _, cookie := range response.Cookies() {
		if cookie.Name == sessionCookieName {
			sessionCookie = cookie
		}
	}
	if sessionCookie == nil || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode {
		t.Fatalf("invalid session cookie: %+v", sessionCookie)
	}

	response, err = app.client.Get(app.server.URL + "/api/auth/session")
	if err != nil {
		t.Fatal(err)
	}
	var sessionResponse struct {
		Data struct {
			CSRFToken string `json:"csrf_token"`
		} `json:"data"`
	}
	if err := json.NewDecoder(response.Body).Decode(&sessionResponse); err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if response.StatusCode != http.StatusOK || sessionResponse.Data.CSRFToken == "" {
		t.Fatalf("session status = %d, body = %+v", response.StatusCode, sessionResponse)
	}

	request, err := http.NewRequest(http.MethodPost, app.server.URL+"/api/auth/logout", nil)
	if err != nil {
		t.Fatal(err)
	}
	response, err = app.client.Do(request)
	if err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if response.StatusCode != http.StatusForbidden {
		t.Fatalf("logout without CSRF status = %d", response.StatusCode)
	}

	request, err = http.NewRequest(http.MethodPost, app.server.URL+"/api/auth/logout", nil)
	if err != nil {
		t.Fatal(err)
	}
	request.Header.Set(csrfHeaderName, sessionResponse.Data.CSRFToken)
	response, err = app.client.Do(request)
	if err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if response.StatusCode != http.StatusOK {
		t.Fatalf("logout status = %d", response.StatusCode)
	}

	response, err = app.client.Get(app.server.URL + "/api/auth/session")
	if err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if response.StatusCode != http.StatusUnauthorized {
		t.Fatalf("session after logout status = %d", response.StatusCode)
	}
}

func TestUnifiedAPIErrors(t *testing.T) {
	app := newTestApplication(t)

	response, err := app.client.Get(app.server.URL + "/api/not-present")
	if err != nil {
		t.Fatal(err)
	}
	if response.StatusCode != http.StatusUnauthorized {
		t.Fatalf("status = %d", response.StatusCode)
	}
	response.Body.Close()

	loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`)
	response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody)
	if err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if response.StatusCode != http.StatusOK {
		t.Fatalf("login status = %d", response.StatusCode)
	}

	response, err = app.client.Get(app.server.URL + "/api/not-present")
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusNotFound {
		t.Fatalf("authenticated not-found status = %d", response.StatusCode)
	}
	var envelope errorEnvelope
	if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
		t.Fatal(err)
	}
	if envelope.Error.Code != "not_found" {
		t.Fatalf("error = %+v", envelope.Error)
	}

	badLogin := bytes.NewBufferString(`{"username":"admin","password":"wrong","extra":true}`)
	response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", badLogin)
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusBadRequest {
		t.Fatalf("invalid JSON status = %d", response.StatusCode)
	}
}

func TestNewRequiresIndex(t *testing.T) {
	database, err := store.Open(context.Background(), ":memory:")
	if err != nil {
		t.Fatal(err)
	}
	defer database.Close()
	authService, err := auth.New(database, auth.Options{
		SessionTTL: time.Hour,
		BcryptCost: bcrypt.MinCost,
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := New(Options{
		Store:  database,
		Auth:   authService,
		Assets: fs.FS(fstest.MapFS{}),
	}); err == nil {
		t.Fatal("New() unexpectedly accepted assets without index.html")
	}
}

func TestSecureCookieAttributes(t *testing.T) {
	recorder := httptest.NewRecorder()
	server := &Server{secureCookies: true}
	server.setAuthCookies(
		recorder,
		"session-token",
		"csrf-token",
		time.Now().Add(time.Hour),
	)

	var sessionCookie *http.Cookie
	var csrfCookie *http.Cookie
	for _, cookie := range recorder.Result().Cookies() {
		switch cookie.Name {
		case sessionCookieName:
			sessionCookie = cookie
		case csrfCookieName:
			csrfCookie = cookie
		}
	}
	if sessionCookie == nil || !sessionCookie.HttpOnly || !sessionCookie.Secure ||
		sessionCookie.SameSite != http.SameSiteStrictMode {
		t.Fatalf("invalid session cookie: %+v", sessionCookie)
	}
	if csrfCookie == nil || csrfCookie.HttpOnly || !csrfCookie.Secure ||
		csrfCookie.SameSite != http.SameSiteStrictMode {
		t.Fatalf("invalid CSRF cookie: %+v", csrfCookie)
	}
}

func TestUIPreferencesDefaultPublicReadAndPersistedWrite(t *testing.T) {
	app := newTestApplication(t)

	readLanguage := func() (int, string) {
		response, err := app.client.Get(app.server.URL + "/api/settings/preferences")
		if err != nil {
			t.Fatal(err)
		}
		defer response.Body.Close()
		var body struct {
			Data struct {
				Language string `json:"language"`
			} `json:"data"`
		}
		if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
			t.Fatal(err)
		}
		return response.StatusCode, body.Data.Language
	}

	status, language := readLanguage()
	if status != http.StatusOK || language != "en" {
		t.Fatalf("default preferences = %d %q", status, language)
	}

	putLanguage := func(value string, csrf string) int {
		request, err := http.NewRequest(
			http.MethodPut,
			app.server.URL+"/api/settings/preferences",
			strings.NewReader(`{"language":`+strconv.Quote(value)+`}`),
		)
		if err != nil {
			t.Fatal(err)
		}
		request.Header.Set("Content-Type", "application/json")
		if csrf != "" {
			request.Header.Set(csrfHeaderName, csrf)
		}
		response, err := app.client.Do(request)
		if err != nil {
			t.Fatal(err)
		}
		defer response.Body.Close()
		return response.StatusCode
	}

	if status := putLanguage("zh", ""); status != http.StatusUnauthorized {
		t.Fatalf("unauthenticated write status = %d", status)
	}

	loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`)
	response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody)
	if err != nil {
		t.Fatal(err)
	}
	var loginResponse struct {
		Data struct {
			CSRFToken string `json:"csrf_token"`
		} `json:"data"`
	}
	if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil {
		t.Fatal(err)
	}
	response.Body.Close()
	if response.StatusCode != http.StatusOK || loginResponse.Data.CSRFToken == "" {
		t.Fatalf("login status = %d", response.StatusCode)
	}

	if status := putLanguage("fr", loginResponse.Data.CSRFToken); status != http.StatusBadRequest {
		t.Fatalf("invalid language status = %d", status)
	}
	if status := putLanguage("zh", loginResponse.Data.CSRFToken); status != http.StatusOK {
		t.Fatalf("write status = %d", status)
	}
	if status, language := readLanguage(); status != http.StatusOK || language != "zh" {
		t.Fatalf("persisted preferences = %d %q", status, language)
	}
}
