package docs

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"reflect"
	"testing"
	"time"

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

func TestNormalizeRequestPathCandidatesSupportsIndexFiles(t *testing.T) {
	tests := []struct {
		name string
		path string
		want []string
	}{
		{
			name: "root",
			path: "",
			want: []string{"index.md", "getting-started.md"},
		},
		{
			name: "clean folder or page path",
			path: "guide",
			want: []string{"guide.md", "guide/index.md"},
		},
		{
			name: "explicit markdown path",
			path: "guide/index.md",
			want: []string{"guide/index.md"},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := normalizeRequestPathCandidates(tt.path)
			if !reflect.DeepEqual(got, tt.want) {
				t.Fatalf("normalizeRequestPathCandidates(%q) = %#v, want %#v", tt.path, got, tt.want)
			}
		})
	}
}

func TestSaveSourcePageWithBaseHashRejectsStaleEditor(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	page, err := service.LoadSourcePage(ctx, "hello")
	if err != nil {
		t.Fatalf("LoadSourcePage() error = %v", err)
	}

	serverContent := "# Hello\n\nServer edit"
	if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
		t.Fatalf("write server edit: %v", err)
	}

	_, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nClient edit", page.Hash)
	var conflict *DocumentConflictError
	if !errors.As(err, &conflict) {
		t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err)
	}
	if conflict.BaseHash != page.Hash {
		t.Fatalf("conflict base hash = %q, want %q", conflict.BaseHash, page.Hash)
	}
	if conflict.CurrentContent != serverContent {
		t.Fatalf("conflict current content = %q, want %q", conflict.CurrentContent, serverContent)
	}

	content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
	if err != nil {
		t.Fatalf("read hello.md: %v", err)
	}
	if string(content) != serverContent {
		t.Fatalf("stale save overwrote content: %q", string(content))
	}
}

func TestSaveSourcePageWithBaseHashAcceptsMatchingHash(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	page, err := service.LoadSourcePage(ctx, "hello")
	if err != nil {
		t.Fatalf("LoadSourcePage() error = %v", err)
	}

	nextContent := "# Hello\n\nClient edit"
	updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", nextContent, page.Hash)
	if err != nil {
		t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err)
	}
	if updated.Hash == page.Hash {
		t.Fatal("expected hash to change after save")
	}

	content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
	if err != nil {
		t.Fatalf("read hello.md: %v", err)
	}
	if string(content) != nextContent {
		t.Fatalf("saved content = %q, want %q", string(content), nextContent)
	}
}

func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	page, err := service.LoadSourcePage(ctx, "hello")
	if err != nil {
		t.Fatalf("LoadSourcePage() error = %v", err)
	}

	serverContent := "# Hello\n\nServer edit"
	if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
		t.Fatalf("write server edit: %v", err)
	}

	_, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nQueued offline edit", page.Hash)
	var conflict *DocumentConflictError
	if !errors.As(err, &conflict) {
		t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err)
	}

	resolvedContent := conflict.CurrentContent + "\n\nResolved local addition"
	updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", resolvedContent, conflict.CurrentHash)
	if err != nil {
		t.Fatalf("SaveSourcePageWithBaseHash() latest hash error = %v", err)
	}
	if updated.Hash == conflict.CurrentHash {
		t.Fatal("expected resolved save to produce a new hash")
	}

	content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
	if err != nil {
		t.Fatalf("read hello.md: %v", err)
	}
	if string(content) != resolvedContent {
		t.Fatalf("resolved content = %q, want %q", string(content), resolvedContent)
	}
}

func TestSaveSourcePageCreatesNewDocument(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	content := "# Inbox Note\n\nCreated from clipboard"
	page, err := service.SaveSourcePageWithBaseHash(ctx, "inbox/note", content, "")
	if err != nil {
		t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err)
	}
	if page.Path != "inbox/note.md" {
		t.Fatalf("page path = %q, want inbox/note.md", page.Path)
	}

	written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "note.md"))
	if err != nil {
		t.Fatalf("read new document: %v", err)
	}
	if string(written) != content {
		t.Fatalf("new document content = %q, want %q", string(written), content)
	}
}

func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
	service, _ := setupDocsTestService(t)
	ctx := context.Background()

	_, err := service.SaveSourcePageWithBaseHash(ctx, "../outside", "# Outside\n", "")
	if err == nil {
		t.Fatal("expected traversal path to be rejected")
	}
}

func TestMoveDocumentPreservesIdentityAndHistory(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	page, err := service.LoadSourcePage(ctx, "hello")
	if err != nil {
		t.Fatalf("LoadSourcePage() error = %v", err)
	}
	before, err := service.repo.GetDocumentByPath(ctx, "hello.md")
	if err != nil {
		t.Fatalf("GetDocumentByPath() error = %v", err)
	}
	var versionsBefore int
	if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsBefore); err != nil {
		t.Fatalf("count versions before move: %v", err)
	}
	if err := service.repo.SaveAttachment(ctx, AttachmentRecord{
		Hash: "attachment-hash", OriginalName: "diagram.png", ContentType: "image/png",
		SizeBytes: 10, CreatedAt: time.Now().UTC(), DocumentPath: before.Path,
	}); err != nil {
		t.Fatalf("save attachment: %v", err)
	}
	if _, err := service.repo.db.ExecContext(ctx, `
		INSERT INTO users (id, email, display_name, created_at) VALUES ('user-move', 'move@example.com', 'Mover', ?)
	`, time.Now().UTC().Format(time.RFC3339)); err != nil {
		t.Fatalf("create permission user: %v", err)
	}
	if _, err := service.repo.db.ExecContext(ctx, `
		INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at)
		VALUES ('permission-move', 'user-move', 'document', ?, 'write', ?)
	`, before.Path, time.Now().UTC().Format(time.RFC3339)); err != nil {
		t.Fatalf("create document permission: %v", err)
	}
	var moveChange DocumentChange
	service.OnChange(func(change DocumentChange) {
		moveChange = change
	})

	moved, err := service.MoveDocument(ctx, "hello.md", "guides/welcome.md", page.Hash)
	if err != nil {
		t.Fatalf("MoveDocument() error = %v", err)
	}
	if moved.ID != before.ID {
		t.Fatalf("moved ID = %q, want %q", moved.ID, before.ID)
	}
	if moved.Path != "guides/welcome.md" {
		t.Fatalf("moved path = %q, want guides/welcome.md", moved.Path)
	}
	if moveChange.Type != "move" || moveChange.DocumentID != moved.ID || moveChange.OldPath != "hello.md" || moveChange.Path != moved.Path {
		t.Fatalf("move change = %#v", moveChange)
	}
	if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("old file still exists or stat failed: %v", err)
	}
	content, err := os.ReadFile(filepath.Join(sourceDir, "guides", "welcome.md"))
	if err != nil {
		t.Fatalf("read moved file: %v", err)
	}
	if string(content) != page.Content {
		t.Fatalf("moved content = %q, want %q", content, page.Content)
	}

	alias, err := service.repo.GetDocumentByPathOrAlias(ctx, "hello.md")
	if err != nil {
		t.Fatalf("resolve old path alias: %v", err)
	}
	if alias.ID != before.ID || alias.Path != moved.Path {
		t.Fatalf("alias resolved to %#v, want ID %q at %q", alias, before.ID, moved.Path)
	}
	var versionsAfter int
	if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsAfter); err != nil {
		t.Fatalf("count versions after move: %v", err)
	}
	if versionsAfter != versionsBefore {
		t.Fatalf("versions after move = %d, want %d", versionsAfter, versionsBefore)
	}
	attachment, err := service.repo.GetAttachment(ctx, "attachment-hash")
	if err != nil {
		t.Fatalf("get moved attachment metadata: %v", err)
	}
	if attachment.DocumentPath != moved.Path {
		t.Fatalf("attachment document path = %q, want %q", attachment.DocumentPath, moved.Path)
	}
	var permissionPath string
	if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-move'`).Scan(&permissionPath); err != nil {
		t.Fatalf("get moved permission: %v", err)
	}
	if permissionPath != moved.Path {
		t.Fatalf("permission resource path = %q, want %q", permissionPath, moved.Path)
	}
}

func TestMoveDocumentRejectsStaleHashAndOccupiedDestination(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()
	if _, err := service.LoadSourcePage(ctx, "hello"); err != nil {
		t.Fatalf("LoadSourcePage() error = %v", err)
	}

	_, err := service.MoveDocument(ctx, "hello.md", "moved.md", "stale")
	var conflict *DocumentMoveConflictError
	if !errors.As(err, &conflict) {
		t.Fatalf("stale MoveDocument() error = %v, want DocumentMoveConflictError", err)
	}
	if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); err != nil {
		t.Fatalf("source file changed after stale move: %v", err)
	}

	if err := os.WriteFile(filepath.Join(sourceDir, "occupied.md"), []byte("# Occupied\n"), 0o644); err != nil {
		t.Fatalf("create occupied destination: %v", err)
	}
	page, err := service.LoadSourcePage(ctx, "hello")
	if err != nil {
		t.Fatalf("reload source page: %v", err)
	}
	_, err = service.MoveDocument(ctx, "hello.md", "occupied.md", page.Hash)
	if !errors.As(err, &conflict) {
		t.Fatalf("occupied MoveDocument() error = %v, want DocumentMoveConflictError", err)
	}
}

func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
	service, _ := setupDocsTestService(t)
	ctx := context.Background()

	if _, err := service.SyncSourceDir(ctx); err != nil {
		t.Fatalf("initial SyncSourceDir() error = %v", err)
	}

	var changed []DocumentChange
	service.OnChange(func(change DocumentChange) {
		changed = append(changed, change)
	})

	if err := service.ArchiveDocument(ctx, "hello.md"); err != nil {
		t.Fatalf("ArchiveDocument() error = %v", err)
	}
	if len(changed) != 1 {
		t.Fatalf("changes after archive = %d, want 1", len(changed))
	}

	changed = nil
	changes, err := service.SyncSourceDir(ctx)
	if err != nil {
		t.Fatalf("SyncSourceDir() error = %v", err)
	}
	if len(changes) != 0 {
		t.Fatalf("sync changes = %#v, want none for archived document", changes)
	}
	if len(changed) != 0 {
		t.Fatalf("broadcast changes = %#v, want none for archived document", changed)
	}
}

func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	for i := 0; i < 100; i++ {
		name := filepath.Join(sourceDir, fmt.Sprintf("note-%03d.md", i))
		content := fmt.Sprintf("# Note %03d\n\nDeterministic body %03d\n", i, i)
		if err := os.WriteFile(name, []byte(content), 0o644); err != nil {
			t.Fatalf("create %s: %v", name, err)
		}
	}

	start := time.Now()
	changes, err := service.SyncSourceDir(ctx)
	if err != nil {
		t.Fatalf("SyncSourceDir() error = %v", err)
	}
	if elapsed := time.Since(start); elapsed > 10*time.Second {
		t.Fatalf("SyncSourceDir() took %s, want <= 10s", elapsed)
	}
	if len(changes) != 101 {
		t.Fatalf("changes = %d, want 101", len(changes))
	}
}

func TestMoveFolderMovesAllDocumentsAndMetadata(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	if err := os.MkdirAll(filepath.Join(sourceDir, "guides", "deep"), 0o755); err != nil {
		t.Fatalf("create guides folder: %v", err)
	}
	if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
		t.Fatalf("create intro.md: %v", err)
	}
	if err := os.WriteFile(filepath.Join(sourceDir, "guides", "deep", "dive.md"), []byte("# Dive\n"), 0o644); err != nil {
		t.Fatalf("create dive.md: %v", err)
	}
	if _, err := service.SyncSourceDir(ctx); err != nil {
		t.Fatalf("SyncSourceDir() error = %v", err)
	}

	if _, err := service.repo.db.ExecContext(ctx, `
		INSERT INTO users (id, email, display_name, created_at) VALUES ('user-folder', 'folder@example.com', 'Folder', ?)
	`, time.Now().UTC().Format(time.RFC3339)); err != nil {
		t.Fatalf("create user: %v", err)
	}
	if _, err := service.repo.db.ExecContext(ctx, `
		INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at)
		VALUES ('permission-folder', 'user-folder', 'folder', 'guides/deep', 'write', ?)
	`, time.Now().UTC().Format(time.RFC3339)); err != nil {
		t.Fatalf("create folder permission: %v", err)
	}
	if _, err := service.repo.db.ExecContext(ctx, `
		INSERT INTO watchers (user_id, document_id, folder_path, created_at)
		VALUES ('user-folder', NULL, 'guides', ?)
	`, time.Now().UTC().Format(time.RFC3339)); err != nil {
		t.Fatalf("create folder watcher: %v", err)
	}

	var changes []DocumentChange
	service.OnChange(func(change DocumentChange) {
		changes = append(changes, change)
	})

	moved, err := service.MoveFolder(ctx, "guides", "handbook")
	if err != nil {
		t.Fatalf("MoveFolder() error = %v", err)
	}
	if moved != 2 {
		t.Fatalf("moved count = %d, want 2", moved)
	}
	for _, oldPath := range []string{"guides/intro.md", "guides/deep/dive.md"} {
		if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(oldPath))); !errors.Is(err, os.ErrNotExist) {
			t.Fatalf("old file %s still exists or stat failed: %v", oldPath, err)
		}
	}
	for oldPath, newPath := range map[string]string{"guides/intro.md": "handbook/intro.md", "guides/deep/dive.md": "handbook/deep/dive.md"} {
		if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(newPath))); err != nil {
			t.Fatalf("new file %s missing: %v", newPath, err)
		}
		record, err := service.repo.GetDocumentByPath(ctx, newPath)
		if err != nil {
			t.Fatalf("get moved record %s: %v", newPath, err)
		}
		alias, err := service.repo.GetDocumentByPathOrAlias(ctx, oldPath)
		if err != nil {
			t.Fatalf("resolve alias for %s: %v", oldPath, err)
		}
		if alias.ID != record.ID {
			t.Fatalf("alias for %s resolved to %q, want %q", oldPath, alias.ID, record.ID)
		}
	}
	if _, err := os.Stat(filepath.Join(sourceDir, "guides")); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("empty source folder not removed: %v", err)
	}

	var permissionFolder string
	if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-folder'`).Scan(&permissionFolder); err != nil {
		t.Fatalf("get folder permission: %v", err)
	}
	if permissionFolder != "handbook/deep" {
		t.Fatalf("permission folder = %q, want handbook/deep", permissionFolder)
	}
	var watcherFolder string
	if err := service.repo.db.QueryRowContext(ctx, `SELECT folder_path FROM watchers WHERE user_id = 'user-folder'`).Scan(&watcherFolder); err != nil {
		t.Fatalf("get watcher: %v", err)
	}
	if watcherFolder != "handbook" {
		t.Fatalf("watcher folder = %q, want handbook", watcherFolder)
	}
	if len(changes) != 2 {
		t.Fatalf("move changes = %d, want 2", len(changes))
	}
	for _, change := range changes {
		if change.Type != "move" {
			t.Fatalf("change type = %q, want move", change.Type)
		}
	}
}

func TestMoveFolderRejectsInvalidDestinations(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil {
		t.Fatalf("create guides folder: %v", err)
	}
	if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
		t.Fatalf("create intro.md: %v", err)
	}
	if _, err := service.SyncSourceDir(ctx); err != nil {
		t.Fatalf("SyncSourceDir() error = %v", err)
	}

	var conflict *DocumentMoveConflictError
	if _, err := service.MoveFolder(ctx, "guides", "guides"); !errors.As(err, &conflict) {
		t.Fatalf("unchanged MoveFolder() error = %v, want DocumentMoveConflictError", err)
	}
	if _, err := service.MoveFolder(ctx, "guides", "guides/nested"); !errors.As(err, &conflict) {
		t.Fatalf("self-nested MoveFolder() error = %v, want DocumentMoveConflictError", err)
	}
	if _, err := service.MoveFolder(ctx, "guides", "../outside"); err == nil {
		t.Fatal("expected traversal destination to be rejected")
	}
	if _, err := service.MoveFolder(ctx, "missing", "handbook"); !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("missing folder MoveFolder() error = %v, want sql.ErrNoRows", err)
	}
	if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil {
		t.Fatalf("source file changed after rejected moves: %v", err)
	}
}

func TestArchiveFolderArchivesAllDocuments(t *testing.T) {
	service, sourceDir := setupDocsTestService(t)
	ctx := context.Background()

	if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil {
		t.Fatalf("create guides folder: %v", err)
	}
	if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
		t.Fatalf("create intro.md: %v", err)
	}
	if err := os.WriteFile(filepath.Join(sourceDir, "guides", "dive.md"), []byte("# Dive\n"), 0o644); err != nil {
		t.Fatalf("create dive.md: %v", err)
	}
	if _, err := service.SyncSourceDir(ctx); err != nil {
		t.Fatalf("SyncSourceDir() error = %v", err)
	}

	archived, err := service.ArchiveFolder(ctx, "guides")
	if err != nil {
		t.Fatalf("ArchiveFolder() error = %v", err)
	}
	if archived != 2 {
		t.Fatalf("archived count = %d, want 2", archived)
	}

	records, err := service.repo.ListDocuments(ctx)
	if err != nil {
		t.Fatalf("ListDocuments() error = %v", err)
	}
	for _, record := range records {
		if record.Path == "guides/intro.md" || record.Path == "guides/dive.md" {
			t.Fatalf("document %s still listed after archive", record.Path)
		}
	}
	archivedRecords, err := service.repo.ListArchivedDocuments(ctx)
	if err != nil {
		t.Fatalf("ListArchivedDocuments() error = %v", err)
	}
	if len(archivedRecords) != 2 {
		t.Fatalf("archived records = %d, want 2", len(archivedRecords))
	}
	if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil {
		t.Fatalf("archived file removed from disk: %v", err)
	}

	if _, err := service.ArchiveFolder(ctx, "guides"); !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("re-archive error = %v, want sql.ErrNoRows", err)
	}
}

func TestNormalizeFolderPathValidation(t *testing.T) {
	tests := []struct {
		name    string
		path    string
		want    string
		wantErr bool
	}{
		{name: "simple", path: "guides", want: "guides"},
		{name: "nested with slashes", path: "/guides/deep/", want: "guides/deep"},
		{name: "empty", path: "", wantErr: true},
		{name: "traversal", path: "../outside", wantErr: true},
		{name: "leading slash", path: "/etc", want: "etc"},
		{name: "markdown suffix", path: "guides.md", wantErr: true},
		{name: "nested markdown segment", path: "guides/notes.md/deep", wantErr: true},
		{name: "cleaned dot segment", path: "guides/./deep", want: "guides/deep"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := NormalizeFolderPath(tt.path)
			if tt.wantErr {
				if err == nil {
					t.Fatalf("NormalizeFolderPath(%q) = %q, want error", tt.path, got)
				}
				return
			}
			if err != nil {
				t.Fatalf("NormalizeFolderPath(%q) error = %v", tt.path, err)
			}
			if got != tt.want {
				t.Fatalf("NormalizeFolderPath(%q) = %q, want %q", tt.path, got, tt.want)
			}
		})
	}
}

func setupDocsTestService(t *testing.T) (*Service, string) {
	t.Helper()

	sourceDir := t.TempDir()
	storeDir := t.TempDir()
	if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nOriginal"), 0o644); err != nil {
		t.Fatalf("create hello.md: %v", err)
	}

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

	ctx := context.Background()
	if err := database.ApplyMigrations(ctx, db); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}

	contentStore, err := store.New(storeDir)
	if err != nil {
		t.Fatalf("create content store: %v", err)
	}

	repo := NewRepository(db)
	return NewService(sourceDir, contentStore, markdown.NewRenderer(), repo, slog.Default()), sourceDir
}
