package httpserver

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"log/slog"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/tim/cairnquire/apps/server/internal/auth"
	"github.com/tim/cairnquire/apps/server/internal/config"
	"github.com/tim/cairnquire/apps/server/internal/database"
	"github.com/tim/cairnquire/apps/server/internal/docs"
	"github.com/tim/cairnquire/apps/server/internal/markdown"
	"github.com/tim/cairnquire/apps/server/internal/store"
)

func TestBuildBrowserUsesFolderIndex(t *testing.T) {
	browser := buildBrowser([]docs.DocumentRecord{
		{
			Path:      "guide/index.md",
			Title:     "Guide",
			UpdatedAt: time.Now(),
		},
		{
			Path:      "guide/setup.md",
			Title:     "Setup",
			UpdatedAt: time.Now(),
		},
	}, "guide/index.md")

	if len(browser.Columns) != 2 {
		t.Fatalf("len(browser.Columns) = %d, want 2", len(browser.Columns))
	}

	root := browser.Columns[0].Items
	if len(root) != 1 {
		t.Fatalf("len(root items) = %d, want 1", len(root))
	}
	if !root[0].IsFolder || root[0].Name != "Guide" || root[0].URL != "/docs/guide" {
		t.Fatalf("root folder = %#v, want Guide folder linking to /docs/guide", root[0])
	}

	child := browser.Columns[1].Items
	if len(child) != 1 {
		t.Fatalf("len(child items) = %d, want 1", len(child))
	}
	if child[0].Path == "guide/index.md" {
		t.Fatalf("folder index file should be represented by the folder, not repeated as a child file")
	}
}

func TestTrimEditSuffix(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		wantPath string
		wantOK   bool
	}{
		{name: "nested path", input: "guide/setup/edit", wantPath: "guide/setup", wantOK: true},
		{name: "root edit sentinel", input: "edit", wantPath: "", wantOK: true},
		{name: "document route", input: "guide/setup", wantPath: "", wantOK: false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			gotPath, gotOK := trimEditSuffix(tt.input)
			if gotPath != tt.wantPath || gotOK != tt.wantOK {
				t.Fatalf("trimEditSuffix(%q) = (%q, %t), want (%q, %t)", tt.input, gotPath, gotOK, tt.wantPath, tt.wantOK)
			}
		})
	}
}

func TestIsContentAssetPath(t *testing.T) {
	tests := []struct {
		path string
		want bool
	}{
		{path: "logo.webp", want: true},
		{path: "guide/logo.png", want: true},
		{path: "guide/page", want: false},
		{path: "guide/page.md", want: false},
		{path: "guide/page/edit", want: false},
	}

	for _, tt := range tests {
		t.Run(tt.path, func(t *testing.T) {
			if got := isContentAssetPath(tt.path); got != tt.want {
				t.Fatalf("isContentAssetPath(%q) = %t, want %t", tt.path, got, tt.want)
			}
		})
	}
}

func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) {
	root := t.TempDir()
	image := []byte{
		0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
		0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
		0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
		0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
		0x89,
	}
	if err := os.WriteFile(filepath.Join(root, "logo.png"), image, 0o644); err != nil {
		t.Fatalf("write fixture: %v", err)
	}

	server := &Server{
		config: config.Config{
			Content: config.ContentConfig{SourceDir: root},
		},
	}
	request := httptest.NewRequest(http.MethodGet, "/docs/logo.png", nil)
	recorder := httptest.NewRecorder()

	server.handleContentAsset(recorder, request, "logo.png")

	response := recorder.Result()
	if response.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
	}
	if got := response.Header.Get("Content-Type"); got != "image/png" {
		t.Fatalf("content-type = %q, want image/png", got)
	}
}

func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
	tests := []struct {
		name     string
		filename string
		content  string
		wantPath string
		wantURL  string
		wantHTML string
	}{
		{
			name:     "markdown",
			filename: "release notes.md",
			content:  "# Release Notes\n\nReadable markdown",
			wantPath: "inbox/release notes.md",
			wantURL:  "/docs/inbox/release%20notes",
			wantHTML: "<p>Readable markdown</p>",
		},
		{
			name:     "html",
			filename: "status.html",
			content:  "<p>Readable HTML</p>",
			wantPath: "inbox/status.md",
			wantURL:  "/docs/inbox/status",
			wantHTML: "<p>Readable HTML</p>",
		},
		{
			name:     "plain text",
			filename: "summary.txt",
			content:  "Readable plain text",
			wantPath: "inbox/summary.md",
			wantURL:  "/docs/inbox/summary",
			wantHTML: "<p>Readable plain text</p>",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			server, sourceDir := setupUploadTestServer(t)
			request := multipartUploadRequest(t, tt.filename, []byte(tt.content), "inbox")
			recorder := httptest.NewRecorder()

			server.handleUpload(recorder, request)

			response := recorder.Result()
			if response.StatusCode != http.StatusCreated {
				t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
			}

			var payload struct {
				Hash          string `json:"hash"`
				Kind          string `json:"kind"`
				URL           string `json:"url"`
				AttachmentURL string `json:"attachmentUrl"`
			}
			if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
				t.Fatalf("decode response: %v", err)
			}
			if payload.Kind != "document" {
				t.Fatalf("kind = %q, want document", payload.Kind)
			}
			if payload.URL != tt.wantURL || payload.AttachmentURL != tt.wantURL {
				t.Fatalf("urls = (%q, %q), want %q", payload.URL, payload.AttachmentURL, tt.wantURL)
			}

			written, err := os.ReadFile(filepath.Join(sourceDir, filepath.FromSlash(tt.wantPath)))
			if err != nil {
				t.Fatalf("read promoted document: %v", err)
			}
			if string(written) != tt.content {
				t.Fatalf("promoted content = %q, want %q", string(written), tt.content)
			}

			attachment, err := server.repository.GetAttachment(context.Background(), payload.Hash)
			if err != nil {
				t.Fatalf("lookup upload history record: %v", err)
			}
			if attachment.DocumentPath != tt.wantPath {
				t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
			}

			listRequest := httptest.NewRequest(http.MethodGet, "/api/attachments", nil)
			listRequest = listRequest.WithContext(withPrincipal(listRequest.Context(), auth.Principal{
				UserID: "user:test-admin",
				Role:   auth.RoleAdmin,
			}))
			listRecorder := httptest.NewRecorder()
			server.handleAttachmentsList(listRecorder, listRequest)
			var listPayload struct {
				Attachments []struct {
					Kind string `json:"kind"`
					URL  string `json:"url"`
				} `json:"attachments"`
			}
			if err := json.NewDecoder(listRecorder.Result().Body).Decode(&listPayload); err != nil {
				t.Fatalf("decode upload history response: %v", err)
			}
			if len(listPayload.Attachments) != 1 || listPayload.Attachments[0].Kind != "document" || listPayload.Attachments[0].URL != tt.wantURL {
				t.Fatalf("upload history = %#v, want rendered document URL %q", listPayload.Attachments, tt.wantURL)
			}

			page, err := server.documents.LoadPage(context.Background(), tt.wantPath)
			if err != nil {
				t.Fatalf("load promoted page: %v", err)
			}
			if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
				t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML)
			}

			grants, err := server.auth.ListResourcePermissions(context.Background(), auth.Principal{
				UserID: "user:test-admin",
				Role:   auth.RoleAdmin,
			}, auth.ResourceDocument, tt.wantPath)
			if err != nil {
				t.Fatalf("list promoted document permissions: %v", err)
			}
			if len(grants) != 1 || grants[0].UserID != "user:test-admin" || grants[0].Permission != auth.PermissionAdmin {
				t.Fatalf("promoted document grants = %#v, want creator admin grant", grants)
			}
		})
	}
}

func TestHandleUploadKeepsPDFAsAttachment(t *testing.T) {
	for _, filename := range []string{"brief.pdf", "renamed.md"} {
		t.Run(filename, func(t *testing.T) {
			server, _ := setupUploadTestServer(t)
			request := multipartUploadRequest(t, filename, []byte("%PDF-1.4\n"), "inbox")
			recorder := httptest.NewRecorder()

			server.handleUpload(recorder, request)

			response := recorder.Result()
			if response.StatusCode != http.StatusCreated {
				t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
			}

			var payload struct {
				Hash string `json:"hash"`
				Kind string `json:"kind"`
				URL  string `json:"url"`
			}
			if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
				t.Fatalf("decode response: %v", err)
			}
			if payload.Kind != "attachment" {
				t.Fatalf("kind = %q, want attachment", payload.Kind)
			}
			if payload.URL != "/attachments/"+payload.Hash {
				t.Fatalf("url = %q, want hash attachment URL", payload.URL)
			}
		})
	}
}

func TestHandleUploadRequiresResolutionForExistingDocumentName(t *testing.T) {
	server, sourceDir := setupUploadTestServer(t)

	first := httptest.NewRecorder()
	server.handleUpload(first, multipartUploadRequest(t, "report.md", []byte("# First"), "inbox"))
	if first.Result().StatusCode != http.StatusCreated {
		t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
	}

	conflict := httptest.NewRecorder()
	server.handleUpload(conflict, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox"))
	if conflict.Result().StatusCode != http.StatusConflict {
		t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
	}
	var payload struct {
		ConflictType string `json:"conflictType"`
		ExistingPath string `json:"existingPath"`
	}
	if err := json.NewDecoder(conflict.Result().Body).Decode(&payload); err != nil {
		t.Fatalf("decode conflict: %v", err)
	}
	if payload.ConflictType != "document" || payload.ExistingPath != "inbox/report.md" {
		t.Fatalf("conflict = %#v, want inbox document conflict", payload)
	}
	written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report.md"))
	if err != nil {
		t.Fatalf("read original document: %v", err)
	}
	if string(written) != "# First" {
		t.Fatalf("original document = %q, want unchanged content", string(written))
	}

	renamed := httptest.NewRecorder()
	server.handleUpload(renamed, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox", "rename"))
	if renamed.Result().StatusCode != http.StatusCreated {
		t.Fatalf("renamed upload status = %d, want %d", renamed.Result().StatusCode, http.StatusCreated)
	}
	renamedContent, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report-2.md"))
	if err != nil {
		t.Fatalf("read renamed document: %v", err)
	}
	if string(renamedContent) != "# Second" {
		t.Fatalf("renamed document = %q, want second content", string(renamedContent))
	}
}

func TestHandleUploadCanReuseExistingAttachment(t *testing.T) {
	server, _ := setupUploadTestServer(t)
	content := []byte("%PDF-1.4\n")

	first := httptest.NewRecorder()
	server.handleUpload(first, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
	if first.Result().StatusCode != http.StatusCreated {
		t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
	}

	conflict := httptest.NewRecorder()
	server.handleUpload(conflict, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
	if conflict.Result().StatusCode != http.StatusConflict {
		t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
	}
	var conflictPayload struct {
		ConflictType string `json:"conflictType"`
		ExistingURL  string `json:"existingUrl"`
	}
	if err := json.NewDecoder(conflict.Result().Body).Decode(&conflictPayload); err != nil {
		t.Fatalf("decode conflict: %v", err)
	}
	if conflictPayload.ConflictType != "attachment" {
		t.Fatalf("conflict type = %q, want attachment", conflictPayload.ConflictType)
	}

	reused := httptest.NewRecorder()
	server.handleUpload(reused, multipartUploadRequest(t, "brief.pdf", content, "inbox", "reuse"))
	if reused.Result().StatusCode != http.StatusOK {
		t.Fatalf("reused upload status = %d, want %d", reused.Result().StatusCode, http.StatusOK)
	}
	var reusedPayload struct {
		URL string `json:"url"`
	}
	if err := json.NewDecoder(reused.Result().Body).Decode(&reusedPayload); err != nil {
		t.Fatalf("decode reused response: %v", err)
	}
	if reusedPayload.URL != conflictPayload.ExistingURL {
		t.Fatalf("reused URL = %q, want %q", reusedPayload.URL, conflictPayload.ExistingURL)
	}
}

func setupUploadTestServer(t *testing.T) (*Server, string) {
	t.Helper()

	db, err := sql.Open("libsql", "file:"+filepath.Join(t.TempDir(), "test.db"))
	if err != nil {
		t.Fatalf("open database: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	if err := database.ApplyMigrations(context.Background(), db); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}

	contentStore, err := store.New(t.TempDir())
	if err != nil {
		t.Fatalf("create content store: %v", err)
	}
	sourceDir := t.TempDir()
	repository := docs.NewRepository(db)
	documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default())
	authRepo := auth.NewRepository(db)
	authService, err := auth.NewService(authRepo, "http://localhost")
	if err != nil {
		t.Fatalf("create auth service: %v", err)
	}
	if _, err := authRepo.UpsertUser(context.Background(), auth.User{
		ID:          "user:test-admin",
		Email:       "test-admin@example.com",
		DisplayName: "Test Admin",
		Role:        auth.RoleAdmin,
	}); err != nil {
		t.Fatalf("create test admin user: %v", err)
	}

	return &Server{
		documents:    documents,
		repository:   repository,
		contentStore: contentStore,
		auth:         authService,
	}, sourceDir
}

func multipartUploadRequest(t *testing.T, filename string, content []byte, folder string, duplicateAction ...string) *http.Request {
	t.Helper()

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	if folder != "" {
		if err := writer.WriteField("folder", folder); err != nil {
			t.Fatalf("write folder: %v", err)
		}
	}
	if len(duplicateAction) > 0 && duplicateAction[0] != "" {
		if err := writer.WriteField("duplicateAction", duplicateAction[0]); err != nil {
			t.Fatalf("write duplicate action: %v", err)
		}
	}
	part, err := writer.CreateFormFile("file", filename)
	if err != nil {
		t.Fatalf("create file part: %v", err)
	}
	if _, err := part.Write(content); err != nil {
		t.Fatalf("write file part: %v", err)
	}
	if err := writer.Close(); err != nil {
		t.Fatalf("close multipart writer: %v", err)
	}

	request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
	request.Header.Set("Content-Type", writer.FormDataContentType())
	request = request.WithContext(withPrincipal(request.Context(), auth.Principal{
		UserID: "user:test-admin",
		Role:   auth.RoleAdmin,
	}))
	return request
}

func TestHandleHealthzReturnsOK(t *testing.T) {
	server := setupHealthTestServer(t)

	recorder := httptest.NewRecorder()
	server.handleHealthz(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))

	if recorder.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
	}
	var payload map[string]string
	if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if payload["status"] != "ok" {
		t.Fatalf("status = %q, want ok", payload["status"])
	}
}

func TestHandleReadyzReportsOKWhenDatabaseAndStoreAreReachable(t *testing.T) {
	server := setupHealthTestServer(t)

	recorder := httptest.NewRecorder()
	server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))

	if recorder.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
	}
	var payload map[string]string
	if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if payload["status"] != "ok" {
		t.Fatalf("status = %q, want ok", payload["status"])
	}
	if payload["database"] != "ok" {
		t.Fatalf("database = %q, want ok", payload["database"])
	}
	if payload["contentStore"] != "ok" {
		t.Fatalf("contentStore = %q, want ok", payload["contentStore"])
	}
}

func TestHandleReadyzReportsUnavailableWhenContentStoreMissing(t *testing.T) {
	server := setupHealthTestServer(t)
	server.config.Content.StoreDir = filepath.Join(t.TempDir(), "does-not-exist")

	recorder := httptest.NewRecorder()
	server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))

	if recorder.Code != http.StatusServiceUnavailable {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
	}
	var payload map[string]string
	if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if payload["status"] != "unavailable" {
		t.Fatalf("status = %q, want unavailable", payload["status"])
	}
	if payload["contentStore"] == "ok" {
		t.Fatalf("contentStore = %q, want non-ok", payload["contentStore"])
	}
}

func TestHandleHealthAliasForReadyz(t *testing.T) {
	server := setupHealthTestServer(t)

	recorder := httptest.NewRecorder()
	server.handleHealth(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))

	if recorder.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
	}
	var payload map[string]string
	if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if payload["status"] != "ok" {
		t.Fatalf("status = %q, want ok", payload["status"])
	}
}

func TestHealthzAndReadyzAreRegisteredOnRouter(t *testing.T) {
	server := newAPITestServer(t)

	for _, path := range []string{"/healthz", "/readyz", "/health"} {
		t.Run(path, func(t *testing.T) {
			recorder := httptest.NewRecorder()
			server.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))

			if recorder.Code != http.StatusOK {
				t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
			}
		})
	}
}

func setupHealthTestServer(t *testing.T) *Server {
	t.Helper()

	root := t.TempDir()
	storeDir := filepath.Join(root, "files")
	if err := os.MkdirAll(storeDir, 0o755); err != nil {
		t.Fatalf("create store dir: %v", err)
	}

	db, err := sql.Open("libsql", "file:"+filepath.Join(root, "test.db"))
	if err != nil {
		t.Fatalf("open database: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	if err := database.ApplyMigrations(context.Background(), db); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}

	contentStore, err := store.New(storeDir)
	if err != nil {
		t.Fatalf("create content store: %v", err)
	}
	repository := docs.NewRepository(db)
	documents := docs.NewService(t.TempDir(), contentStore, markdown.NewRenderer(), repository, slog.Default())

	return &Server{
		config: config.Config{
			Content: config.ContentConfig{StoreDir: storeDir},
		},
		documents:    documents,
		repository:   repository,
		contentStore: contentStore,
	}
}
