package docs

import (
	"encoding/json"
	"net/http"
	"path/filepath"
	"testing"

	"github.com/go-chi/chi/v5"

	"gitea.parodia.dev/drwily/petal/internal/db"
)

// newFullServer mounts the document, tag, and search routers under the same base
// paths as the real server (/docs, /tags, /search) so tests can exercise the
// cross-router flows (create a doc, tag it, list, search).
func newFullServer(t *testing.T) http.Handler {
	t.Helper()
	database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	t.Cleanup(func() { database.Close() })

	h := New(database)
	r := chi.NewRouter()
	r.Mount("/docs", h.Routes())
	r.Mount("/tags", h.TagRoutes())
	r.Mount("/search", h.SearchRoutes())
	return r
}

// createDoc makes a document with the given title/body and returns its id.
func createDoc(t *testing.T, srv http.Handler, title, text string) string {
	t.Helper()
	rec := do(t, srv, http.MethodPost, "/docs", "")
	if rec.Code != http.StatusCreated {
		t.Fatalf("create doc: %d %s", rec.Code, rec.Body)
	}
	var d db.Document
	_ = json.Unmarshal(rec.Body.Bytes(), &d)

	body, _ := json.Marshal(map[string]any{
		"title": title, "content": "{}", "content_text": text, "word_count": 1,
	})
	rec = do(t, srv, http.MethodPut, "/docs/"+d.ID, string(body))
	if rec.Code != http.StatusOK {
		t.Fatalf("save doc: %d %s", rec.Code, rec.Body)
	}
	return d.ID
}

func TestTagLifecycle(t *testing.T) {
	srv := newFullServer(t)

	// Empty roster serializes as [].
	rec := do(t, srv, http.MethodGet, "/tags", "")
	if rec.Code != http.StatusOK || rec.Body.String() == "null\n" {
		t.Fatalf("empty tags: %d %s", rec.Code, rec.Body)
	}

	// Create a tag.
	rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"lavender"}`)
	if rec.Code != http.StatusCreated {
		t.Fatalf("create tag: %d %s", rec.Code, rec.Body)
	}
	var tag db.Tag
	_ = json.Unmarshal(rec.Body.Bytes(), &tag)
	if tag.ID == "" || tag.Name != "日记" || tag.Color != "lavender" {
		t.Fatalf("bad created tag: %+v", tag)
	}

	// Re-creating the same name is idempotent (returns the same id, keeps color).
	rec = do(t, srv, http.MethodPost, "/tags", `{"name":"日记","color":"sky"}`)
	var again db.Tag
	_ = json.Unmarshal(rec.Body.Bytes(), &again)
	if again.ID != tag.ID || again.Color != "lavender" {
		t.Fatalf("create not idempotent: %+v vs %+v", again, tag)
	}

	// Unknown color is coerced to rose.
	rec = do(t, srv, http.MethodPost, "/tags", `{"name":"work","color":"chartreuse"}`)
	var work db.Tag
	_ = json.Unmarshal(rec.Body.Bytes(), &work)
	if work.Color != "rose" {
		t.Fatalf("unknown color not coerced: %+v", work)
	}

	// Empty name is rejected.
	if rec = do(t, srv, http.MethodPost, "/tags", `{"name":"  "}`); rec.Code != http.StatusBadRequest {
		t.Fatalf("empty name should be 400: %d", rec.Code)
	}

	// Recolor + rename via PATCH.
	rec = do(t, srv, http.MethodPatch, "/tags/"+tag.ID, `{"color":"honey"}`)
	var recolored db.Tag
	_ = json.Unmarshal(rec.Body.Bytes(), &recolored)
	if recolored.Color != "honey" || recolored.Name != "日记" {
		t.Fatalf("patch clobbered/failed: %+v", recolored)
	}

	// Assign both tags to a document.
	docID := createDoc(t, srv, "My Journal", "today was a good day")
	if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
		t.Fatalf("assign tag: %d %s", rec.Code, rec.Body)
	}
	// Re-assigning is idempotent.
	if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+tag.ID+`"}`); rec.Code != http.StatusNoContent {
		t.Fatalf("re-assign tag: %d", rec.Code)
	}
	if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNoContent {
		t.Fatalf("assign 2nd tag: %d", rec.Code)
	}

	// The doc list now carries both tags, ordered by name.
	rec = do(t, srv, http.MethodGet, "/docs", "")
	var docs []docSummary
	_ = json.Unmarshal(rec.Body.Bytes(), &docs)
	if len(docs) != 1 || len(docs[0].Tags) != 2 {
		t.Fatalf("doc list tags: %+v", docs)
	}

	// Tag list reports doc counts.
	rec = do(t, srv, http.MethodGet, "/tags", "")
	var roster []db.Tag
	_ = json.Unmarshal(rec.Body.Bytes(), &roster)
	var found bool
	for _, rt := range roster {
		if rt.ID == tag.ID {
			found = true
			if rt.DocCount != 1 {
				t.Fatalf("expected doc_count 1, got %d", rt.DocCount)
			}
		}
	}
	if !found {
		t.Fatalf("tag missing from roster: %+v", roster)
	}

	// Unassign one tag.
	if rec = do(t, srv, http.MethodDelete, "/docs/"+docID+"/tags/"+work.ID, ""); rec.Code != http.StatusNoContent {
		t.Fatalf("unassign: %d", rec.Code)
	}
	rec = do(t, srv, http.MethodGet, "/docs", "")
	_ = json.Unmarshal(rec.Body.Bytes(), &docs)
	if len(docs[0].Tags) != 1 || docs[0].Tags[0].ID != tag.ID {
		t.Fatalf("after unassign: %+v", docs[0].Tags)
	}

	// Deleting a tag cascades its assignments away.
	if rec = do(t, srv, http.MethodDelete, "/tags/"+tag.ID, ""); rec.Code != http.StatusNoContent {
		t.Fatalf("delete tag: %d", rec.Code)
	}
	rec = do(t, srv, http.MethodGet, "/docs", "")
	_ = json.Unmarshal(rec.Body.Bytes(), &docs)
	if len(docs[0].Tags) != 0 {
		t.Fatalf("tag delete did not cascade: %+v", docs[0].Tags)
	}

	// Assigning a non-existent tag 404s; tagging a non-existent doc 404s.
	if rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/tags", `{"tag_id":"nope"}`); rec.Code != http.StatusNotFound {
		t.Fatalf("assign unknown tag: %d", rec.Code)
	}
	if rec = do(t, srv, http.MethodPost, "/docs/nope/tags", `{"tag_id":"`+work.ID+`"}`); rec.Code != http.StatusNotFound {
		t.Fatalf("assign to unknown doc: %d", rec.Code)
	}
}
