package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"testing"
)

// newTestServer richtet eine frische, isolierte DB (eigene Datei je Test) und
// einen httptest-Server mit dem echten Routing ein.
func newTestServer(t *testing.T) *httptest.Server {
	t.Helper()

	dsn := filepath.Join(t.TempDir(), "test.db")
	if err := initDB(dsn); err != nil {
		t.Fatalf("initDB: %v", err)
	}
	t.Cleanup(func() { db.Close() })

	srv := httptest.NewServer(routes())
	t.Cleanup(srv.Close)
	return srv
}

// newClient liefert einen HTTP-Client mit Cookie-Jar (hält Session-Cookies).
func newClient(t *testing.T) *http.Client {
	t.Helper()
	jar, err := cookiejar.New(nil)
	if err != nil {
		t.Fatal(err)
	}
	return &http.Client{Jar: jar}
}

func postForm(t *testing.T, c *http.Client, urlStr string, form url.Values) *http.Response {
	t.Helper()
	resp, err := c.PostForm(urlStr, form)
	if err != nil {
		t.Fatalf("POST %s: %v", urlStr, err)
	}
	return resp
}

// registerAndLogin legt einen Nutzer an, loggt ihn ein und gibt den Client
// (mit gesetztem Session-Cookie) zurück.
func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http.Client {
	t.Helper()
	c := newClient(t)

	resp := postForm(t, c, srv.URL+"/auth/newuser", url.Values{
		"user": {username}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"},
	})
	resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("register %s: status %d", username, resp.StatusCode)
	}

	resp = postForm(t, c, srv.URL+"/auth/login", url.Values{
		"user": {username}, "pass": {"supersecret1"}, "timeout": {"86400"},
	})
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("login %s: status %d", username, resp.StatusCode)
	}
	return c
}

// createEntry erstellt einen Beitrag und liefert dessen pid.
func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content string) int64 {
	t.Helper()
	resp := postForm(t, c, srv.URL+"/entry/create", url.Values{"content": {content}})
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("create entry: status %d", resp.StatusCode)
	}
	var out struct {
		PID int64 `json:"pid"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		t.Fatalf("decode create: %v", err)
	}
	return out.PID
}

func TestRegisterLoginHeaderbar(t *testing.T) {
	srv := newTestServer(t)
	c := registerAndLogin(t, srv, "alice")

	resp, err := c.Get(srv.URL + "/auth/headerbar")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	var h struct {
		LoggedIn bool `json:"loggedin"`
	}
	json.NewDecoder(resp.Body).Decode(&h)
	if !h.LoggedIn {
		t.Fatal("erwartete loggedin=true nach Login")
	}

	// Anonymer Client ist nicht eingeloggt.
	anon := newClient(t)
	resp2, err := anon.Get(srv.URL + "/auth/headerbar")
	if err != nil {
		t.Fatal(err)
	}
	defer resp2.Body.Close()
	var h2 struct {
		LoggedIn bool `json:"loggedin"`
	}
	json.NewDecoder(resp2.Body).Decode(&h2)
	if h2.LoggedIn {
		t.Fatal("anonymer Client sollte nicht eingeloggt sein")
	}
}

func TestFeedEmptyThenPopulated(t *testing.T) {
	srv := newTestServer(t)

	resp, err := http.Get(srv.URL + "/entry/feed/0")
	if err != nil {
		t.Fatal(err)
	}
	var empty []map[string]any
	json.NewDecoder(resp.Body).Decode(&empty)
	resp.Body.Close()
	if len(empty) != 0 {
		t.Fatalf("erwartete leeren Feed, bekam %d", len(empty))
	}

	alice := registerAndLogin(t, srv, "alice")
	createEntry(t, alice, srv, "hallo welt")

	resp, err = http.Get(srv.URL + "/entry/feed/0")
	if err != nil {
		t.Fatal(err)
	}
	var posts []struct {
		Content  string `json:"content"`
		Username string `json:"username"`
	}
	json.NewDecoder(resp.Body).Decode(&posts)
	resp.Body.Close()
	if len(posts) != 1 || posts[0].Content != "hallo welt" || posts[0].Username != "alice" {
		t.Fatalf("unerwarteter Feed: %+v", posts)
	}
}

func TestCreateEntryRequiresAuth(t *testing.T) {
	srv := newTestServer(t)

	anon := newClient(t)
	resp := postForm(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("create ohne Login: erwartet 401, bekam %d", resp.StatusCode)
	}
}

type tally struct {
	Left     int    `json:"left"`
	Right    int    `json:"right"`
	Selected string `json:"selected"`
}

func TestVoting(t *testing.T) {
	srv := newTestServer(t)
	alice := registerAndLogin(t, srv, "alice")
	bob := registerAndLogin(t, srv, "bob")
	pid := createEntry(t, alice, srv, "vote me")

	vote := func(c *http.Client, mode string) tally {
		resp := postForm(t, c, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), url.Values{"mode": {mode}})
		defer resp.Body.Close()
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("vote %s: status %d", mode, resp.StatusCode)
		}
		var tl tally
		json.NewDecoder(resp.Body).Decode(&tl)
		return tl
	}

	if got := vote(alice, "left"); got.Left != 1 || got.Selected != "left" {
		t.Fatalf("alice left: %+v", got)
	}
	if got := vote(bob, "right"); got.Left != 1 || got.Right != 1 {
		t.Fatalf("bob right: %+v", got)
	}
	if got := vote(alice, "left"); got.Left != 0 || got.Selected != "none" {
		t.Fatalf("alice toggle off: %+v", got)
	}
	if got := vote(alice, "right"); got.Right != 2 || got.Selected != "right" {
		t.Fatalf("alice switch: %+v", got)
	}
}

func TestVoteRequiresAuthAndValidMode(t *testing.T) {
	srv := newTestServer(t)
	alice := registerAndLogin(t, srv, "alice")
	pid := createEntry(t, alice, srv, "x")
	voteURL := fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid)

	anon := newClient(t)
	resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode)
	}

	resp = postForm(t, alice, voteURL, url.Values{"mode": {"up"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("ungültiger Modus: erwartet 400, bekam %d", resp.StatusCode)
	}
}

// createReply erstellt eine Antwort auf parentPID und liefert deren pid.
func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
	t.Helper()
	resp := postForm(t, c, srv.URL+"/entry/create", url.Values{
		"content": {content}, "reply_to": {fmt.Sprint(parentPID)},
	})
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("create reply: status %d", resp.StatusCode)
	}
	var out struct {
		PID int64 `json:"pid"`
	}
	json.NewDecoder(resp.Body).Decode(&out)
	return out.PID
}

type threadEntry struct {
	PID        int64  `json:"pid"`
	ReplyCount int64  `json:"reply_count"`
	Deleted    int64  `json:"deleted"`
	Content    string `json:"content"`
	Username   string `json:"username"`
}

type threadView struct {
	Entry     threadEntry   `json:"entry"`
	Ancestors []threadEntry `json:"ancestors"`
	Replies   []threadEntry `json:"replies"`
}

func getThread(t *testing.T, srv *httptest.Server, pid int64) threadView {
	t.Helper()
	resp, err := http.Get(fmt.Sprintf("%s/entry/%d/thread", srv.URL, pid))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("thread %d: status %d", pid, resp.StatusCode)
	}
	var tv threadView
	json.NewDecoder(resp.Body).Decode(&tv)
	return tv
}

func TestThreading(t *testing.T) {
	srv := newTestServer(t)
	alice := registerAndLogin(t, srv, "alice")

	root := createEntry(t, alice, srv, "wurzel")
	mid := createReply(t, alice, srv, "antwort 1", root)
	createReply(t, alice, srv, "antwort 2", mid) // tiefere Antwort

	// reply_count bubblet bis zum Root: Root = 2 (ganzer Teilbaum), mid = 1.
	if tv := getThread(t, srv, root); tv.Entry.ReplyCount != 2 {
		t.Fatalf("root reply_count: erwartet 2, bekam %d", tv.Entry.ReplyCount)
	}
	rootThread := getThread(t, srv, root)
	if len(rootThread.Replies) != 1 || rootThread.Replies[0].PID != mid {
		t.Fatalf("root replies: erwartet [%d], bekam %+v", mid, rootThread.Replies)
	}
	if len(rootThread.Ancestors) != 0 {
		t.Fatalf("root ancestors: erwartet leer, bekam %+v", rootThread.Ancestors)
	}

	midThread := getThread(t, srv, mid)
	if midThread.Entry.ReplyCount != 1 {
		t.Fatalf("mid reply_count: erwartet 1, bekam %d", midThread.Entry.ReplyCount)
	}
	if len(midThread.Ancestors) != 1 || midThread.Ancestors[0].PID != root {
		t.Fatalf("mid ancestors: erwartet [%d], bekam %+v", root, midThread.Ancestors)
	}

	// Hauptfeed: nur der Root, keine Antworten.
	resp, _ := http.Get(srv.URL + "/entry/feed/0")
	var feed []struct {
		PID int64 `json:"pid"`
	}
	json.NewDecoder(resp.Body).Decode(&feed)
	resp.Body.Close()
	if len(feed) != 1 || feed[0].PID != root {
		t.Fatalf("hauptfeed: erwartet nur Root %d, bekam %+v", root, feed)
	}

	// Profil-Feed: alle drei Beiträge (Root + Antworten).
	resp, _ = http.Get(srv.URL + "/u/alice/feed/0")
	var profile []struct {
		PID int64 `json:"pid"`
	}
	json.NewDecoder(resp.Body).Decode(&profile)
	resp.Body.Close()
	if len(profile) != 3 {
		t.Fatalf("profil-feed: erwartet 3 Beiträge, bekam %d", len(profile))
	}
}

func TestEditEntry(t *testing.T) {
	srv := newTestServer(t)
	alice := registerAndLogin(t, srv, "alice")
	bob := registerAndLogin(t, srv, "bob")

	pid := createEntry(t, alice, srv, "original")
	editURL := fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid)

	// Eigentümer bearbeitet -> 200, neuer Inhalt sichtbar.
	resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("edit: erwartet 200, bekam %d", resp.StatusCode)
	}
	if tv := getThread(t, srv, pid); tv.Entry.Content != "korrigiert" {
		t.Fatalf("inhalt nach edit: %q", tv.Entry.Content)
	}

	// Leerer Inhalt -> 400.
	resp = postForm(t, alice, editURL, url.Values{"content": {"   "}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("leerer edit: erwartet 400, bekam %d", resp.StatusCode)
	}

	// Fremder -> 403.
	resp = postForm(t, bob, editURL, url.Values{"content": {"hijack"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("fremd-edit: erwartet 403, bekam %d", resp.StatusCode)
	}

	// Unbekannte pid -> 404.
	resp = postForm(t, alice, srv.URL+"/entry/999999/edit", url.Values{"content": {"x"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("edit unbekannt: erwartet 404, bekam %d", resp.StatusCode)
	}

	// Nach Soft-Delete ist der Beitrag nicht mehr bearbeitbar (uid=0) -> 403.
	postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close()
	resp = postForm(t, alice, editURL, url.Values{"content": {"wieder da"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("edit nach delete: erwartet 403, bekam %d", resp.StatusCode)
	}
}

func TestSoftDelete(t *testing.T) {
	srv := newTestServer(t)
	alice := registerAndLogin(t, srv, "alice")
	bob := registerAndLogin(t, srv, "bob")

	root := createEntry(t, alice, srv, "wurzel")
	reply := createReply(t, alice, srv, "antwort", root)

	delURL := fmt.Sprintf("%s/entry/%d/delete", srv.URL, root)

	// Fremder darf nicht löschen.
	resp := postForm(t, bob, delURL, url.Values{})
	resp.Body.Close()
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("fremd-delete: erwartet 403, bekam %d", resp.StatusCode)
	}

	// Eigentümer löscht den Root weich.
	resp = postForm(t, alice, delURL, url.Values{})
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode)
	}

	// Root bleibt als [deleted]-Platzhalter; die Antwort verwaist nicht.
	tv := getThread(t, srv, root)
	if tv.Entry.Deleted != 1 || tv.Entry.Content != "" || tv.Entry.Username != "" {
		t.Fatalf("deleted entry nicht anonymisiert: %+v", tv.Entry)
	}
	if len(tv.Replies) != 1 || tv.Replies[0].PID != reply {
		t.Fatalf("antwort verwaist: %+v", tv.Replies)
	}

	// Der gelöschte Root erscheint weiterhin im Hauptfeed (Thread bleibt erreichbar).
	feedResp, _ := http.Get(srv.URL + "/entry/feed/0")
	var feed []threadEntry
	json.NewDecoder(feedResp.Body).Decode(&feed)
	feedResp.Body.Close()
	if len(feed) != 1 || feed[0].PID != root || feed[0].Deleted != 1 {
		t.Fatalf("hauptfeed nach delete: %+v", feed)
	}

	// Nicht existierender Beitrag -> 404.
	resp = postForm(t, alice, srv.URL+"/entry/999999/delete", url.Values{})
	resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode)
	}
}

func TestRenameUser(t *testing.T) {
	srv := newTestServer(t)
	alice := registerAndLogin(t, srv, "alice")
	registerAndLogin(t, srv, "bob")

	// Zu kurz -> 400.
	resp := postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"ab"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("zu kurz: erwartet 400, bekam %d", resp.StatusCode)
	}

	// Bereits vergeben -> 409.
	resp = postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"bob"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("vergeben: erwartet 409, bekam %d", resp.StatusCode)
	}

	// Gültig -> 200 + neuer Name.
	resp = postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"alice2"}})
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("rename: erwartet 200, bekam %d", resp.StatusCode)
	}
	var out struct {
		Username string `json:"username"`
	}
	json.NewDecoder(resp.Body).Decode(&out)
	if out.Username != "alice2" {
		t.Fatalf("erwarteter Name alice2, bekam %q", out.Username)
	}

	// Profil unter dem neuen Namen erreichbar, alter Name weg.
	r2, _ := http.Get(srv.URL + "/u/alice2/info")
	r2.Body.Close()
	if r2.StatusCode != http.StatusOK {
		t.Fatalf("/u/alice2/info: erwartet 200, bekam %d", r2.StatusCode)
	}
	r3, _ := http.Get(srv.URL + "/u/alice/info")
	r3.Body.Close()
	if r3.StatusCode != http.StatusNotFound {
		t.Fatalf("/u/alice/info: erwartet 404, bekam %d", r3.StatusCode)
	}
}

func TestDeleteUser(t *testing.T) {
	srv := newTestServer(t)
	alice := registerAndLogin(t, srv, "alice")
	bob := registerAndLogin(t, srv, "bob")

	// alice schreibt einen Beitrag, bob antwortet darauf.
	root := createEntry(t, alice, srv, "alices beitrag")
	reply := createReply(t, bob, srv, "bobs antwort", root)

	// Falsches Passwort -> 403.
	resp := postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"falsch"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("falsches Passwort: erwartet 403, bekam %d", resp.StatusCode)
	}

	// Richtiges Passwort -> 200.
	resp = postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"supersecret1"}})
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode)
	}

	// Login danach schlägt fehl.
	fresh := newClient(t)
	resp = postForm(t, fresh, srv.URL+"/auth/login", url.Values{
		"user": {"alice"}, "pass": {"supersecret1"}, "timeout": {"86400"},
	})
	resp.Body.Close()
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("Login nach Löschen: erwartet 401, bekam %d", resp.StatusCode)
	}

	// alices Beitrag bleibt als [deleted]-Platzhalter; bobs Antwort verwaist nicht.
	tv := getThread(t, srv, root)
	if tv.Entry.Deleted != 1 || tv.Entry.Content != "" || tv.Entry.Username != "" {
		t.Fatalf("beitrag nach konto-löschung nicht soft-deleted: %+v", tv.Entry)
	}
	if len(tv.Replies) != 1 || tv.Replies[0].PID != reply {
		t.Fatalf("bobs antwort verwaist: %+v", tv.Replies)
	}
}
