package docs

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"io/fs"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"sync"

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

type Service struct {
	sourceDir string
	store     *store.ContentStore
	renderer  *markdown.Renderer
	repo      *Repository
	logger    *slog.Logger
	mu        sync.Mutex
	onChange  func(DocumentChange)
}

type DocumentChange struct {
	Type         string `json:"type,omitempty"`
	DocumentID   string `json:"documentId"`
	Path         string `json:"path"`
	OldPath      string `json:"oldPath,omitempty"`
	Title        string `json:"title"`
	Hash         string `json:"hash"`
	PreviousHash string `json:"previousHash,omitempty"`
}

type DocumentMoveConflictError struct {
	OldPath string
	NewPath string
	Reason  string
}

func (e *DocumentMoveConflictError) Error() string {
	return fmt.Sprintf("cannot move %s to %s: %s", e.OldPath, e.NewPath, e.Reason)
}

type Page struct {
	Path  string
	Title string
	Tags  []string
	HTML  string
	Hash  string
}

type SourcePage struct {
	Path    string
	Title   string
	Tags    []string
	Content string
	Hash    string
}

type DocumentConflictError struct {
	Path           string
	BaseHash       string
	CurrentHash    string
	CurrentContent string
}

func (e *DocumentConflictError) Error() string {
	return fmt.Sprintf("document %s changed since editor loaded", e.Path)
}

func NewService(sourceDir string, store *store.ContentStore, renderer *markdown.Renderer, repo *Repository, logger *slog.Logger) *Service {
	return &Service{
		sourceDir: sourceDir,
		store:     store,
		renderer:  renderer,
		repo:      repo,
		logger:    logger,
	}
}

func (s *Service) OnChange(callback func(DocumentChange)) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.onChange = callback
}

func (s *Service) SyncSourceDir(ctx context.Context) ([]DocumentChange, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	return s.syncSourceDirLocked(ctx)
}

func (s *Service) syncSourceDirLocked(ctx context.Context) ([]DocumentChange, error) {

	var changes []DocumentChange
	err := filepath.WalkDir(s.sourceDir, func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if entry.IsDir() || filepath.Ext(path) != ".md" {
			return nil
		}

		change, err := s.syncFile(ctx, path)
		if err != nil {
			return err
		}
		if change != nil {
			changes = append(changes, *change)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}

	if s.onChange != nil {
		for _, change := range changes {
			s.onChange(change)
		}
	}

	return changes, nil
}

func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
	if _, err := s.SyncSourceDir(ctx); err != nil {
		return nil, err
	}
	return s.repo.ListDocuments(ctx)
}

func (s *Service) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) {
	return s.repo.ListArchivedDocuments(ctx)
}

func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, err := s.syncSourceDirLocked(ctx); err != nil {
		return err
	}

	record, _, err := s.resolveRecord(ctx, path)
	if err != nil {
		return err
	}

	if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil {
		return err
	}

	if s.onChange != nil {
		s.onChange(DocumentChange{
			DocumentID: record.ID,
			Path:       record.Path,
			Title:      record.Title,
			Hash:       record.CurrentHash,
		})
	}

	return nil
}

func (s *Service) RestoreDocument(ctx context.Context, path string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if err := s.repo.RestoreDocument(ctx, path); err != nil {
		return err
	}

	if _, err := s.syncSourceDirLocked(ctx); err != nil {
		return err
	}

	return nil
}

func (s *Service) MoveDocument(ctx context.Context, requestPath, destinationPath, expectedHash string) (*DocumentRecord, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, err := s.syncSourceDirLocked(ctx); err != nil {
		return nil, err
	}
	oldPath, err := NormalizeDocumentPath(requestPath)
	if err != nil {
		return nil, err
	}
	newPath, err := NormalizeDocumentPath(destinationPath)
	if err != nil {
		return nil, err
	}
	if oldPath == newPath {
		return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"}
	}

	record, err := s.repo.GetDocumentByPath(ctx, oldPath)
	if err != nil {
		return nil, err
	}
	if expectedHash != "" && record.CurrentHash != expectedHash {
		return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "document changed since the page loaded"}
	}
	if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, newPath); err == nil {
		return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists"}
	} else if !errors.Is(err, sql.ErrNoRows) {
		return nil, err
	}
	if aliased, err := s.repo.GetDocumentByPathOrAlias(ctx, newPath); err == nil && aliased.ID != record.ID {
		return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is reserved by another document"}
	} else if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return nil, err
	}

	oldFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(oldPath))
	newFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(newPath))
	if _, err := os.Stat(newFullPath); err == nil {
		return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists"}
	} else if !errors.Is(err, os.ErrNotExist) {
		return nil, fmt.Errorf("check destination file: %w", err)
	}
	content, err := os.ReadFile(oldFullPath)
	if err != nil {
		return nil, fmt.Errorf("read source document: %w", err)
	}
	rendered, err := s.renderer.RenderPath(content, newPath)
	if err != nil {
		return nil, fmt.Errorf("render moved document: %w", err)
	}
	if err := os.MkdirAll(filepath.Dir(newFullPath), 0o755); err != nil {
		return nil, fmt.Errorf("create destination folder: %w", err)
	}
	if err := os.Rename(oldFullPath, newFullPath); err != nil {
		return nil, fmt.Errorf("move document file: %w", err)
	}

	moveErr := s.repo.MoveDocument(ctx, MoveDocumentInput{
		ID:           record.ID,
		OldPath:      oldPath,
		NewPath:      newPath,
		ExpectedHash: record.CurrentHash,
		Title:        rendered.Title,
	})
	if moveErr != nil {
		if rollbackErr := os.Rename(newFullPath, oldFullPath); rollbackErr != nil {
			return nil, fmt.Errorf("move database update failed: %v; filesystem rollback failed: %w", moveErr, rollbackErr)
		}
		return nil, moveErr
	}

	updated, err := s.repo.GetDocumentByID(ctx, record.ID)
	if err != nil {
		return nil, err
	}
	if s.onChange != nil {
		s.onChange(DocumentChange{
			Type:       "move",
			DocumentID: updated.ID,
			Path:       updated.Path,
			OldPath:    oldPath,
			Title:      updated.Title,
			Hash:       updated.CurrentHash,
		})
	}
	return updated, nil
}

func (s *Service) MoveFolder(ctx context.Context, oldPrefix, newPrefix string) (int, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, err := s.syncSourceDirLocked(ctx); err != nil {
		return 0, err
	}
	oldPath, err := NormalizeFolderPath(oldPrefix)
	if err != nil {
		return 0, err
	}
	newPath, err := NormalizeFolderPath(newPrefix)
	if err != nil {
		return 0, err
	}
	if oldPath == newPath {
		return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"}
	}
	if strings.HasPrefix(newPath, oldPath+"/") {
		return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "cannot move a folder into itself"}
	}

	records, err := s.repo.ListDocuments(ctx)
	if err != nil {
		return 0, err
	}
	moving := make([]DocumentRecord, 0)
	for _, record := range records {
		if strings.HasPrefix(record.Path, oldPath+"/") {
			moving = append(moving, record)
		}
	}
	if len(moving) == 0 {
		return 0, sql.ErrNoRows
	}

	newFullDir := filepath.Join(s.sourceDir, filepath.FromSlash(newPath))
	if info, err := os.Stat(newFullDir); err == nil && info.IsDir() {
		return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination folder already exists"}
	} else if err != nil && !errors.Is(err, os.ErrNotExist) {
		return 0, fmt.Errorf("check destination folder: %w", err)
	}

	for _, record := range moving {
		destination := newPath + strings.TrimPrefix(record.Path, oldPath)
		if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, destination); err == nil {
			return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists: " + destination}
		} else if !errors.Is(err, sql.ErrNoRows) {
			return 0, err
		}
		if _, err := os.Stat(filepath.Join(s.sourceDir, filepath.FromSlash(destination))); err == nil {
			return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists: " + destination}
		} else if !errors.Is(err, os.ErrNotExist) {
			return 0, fmt.Errorf("check destination file: %w", err)
		}
	}

	type movedPair struct {
		record    DocumentRecord
		newPath   string
		newTitle  string
		oldFull   string
		newFull   string
		dbUpdated bool
	}
	moved := make([]movedPair, 0, len(moving))
	rollback := func(moveErr error) (int, error) {
		for i := len(moved) - 1; i >= 0; i-- {
			pair := moved[i]
			if pair.dbUpdated {
				if err := s.repo.MoveDocument(ctx, MoveDocumentInput{
					ID:           pair.record.ID,
					OldPath:      pair.newPath,
					NewPath:      pair.record.Path,
					ExpectedHash: pair.record.CurrentHash,
					Title:        pair.record.Title,
				}); err != nil {
					return 0, fmt.Errorf("move folder failed: %v; database rollback failed for %s: %w", moveErr, pair.newPath, err)
				}
			}
			if err := os.Rename(pair.newFull, pair.oldFull); err != nil {
				return 0, fmt.Errorf("move folder failed: %v; filesystem rollback failed for %s: %w", moveErr, pair.newFull, err)
			}
		}
		return 0, moveErr
	}

	for _, record := range moving {
		destination := newPath + strings.TrimPrefix(record.Path, oldPath)
		oldFull := filepath.Join(s.sourceDir, filepath.FromSlash(record.Path))
		newFull := filepath.Join(s.sourceDir, filepath.FromSlash(destination))
		content, err := os.ReadFile(oldFull)
		if err != nil {
			return rollback(fmt.Errorf("read source document: %w", err))
		}
		rendered, err := s.renderer.RenderPath(content, destination)
		if err != nil {
			return rollback(fmt.Errorf("render moved document: %w", err))
		}
		if err := os.MkdirAll(filepath.Dir(newFull), 0o755); err != nil {
			return rollback(fmt.Errorf("create destination folder: %w", err))
		}
		if err := os.Rename(oldFull, newFull); err != nil {
			return rollback(fmt.Errorf("move document file: %w", err))
		}
		pair := movedPair{record: record, newPath: destination, newTitle: rendered.Title, oldFull: oldFull, newFull: newFull}
		if err := s.repo.MoveDocument(ctx, MoveDocumentInput{
			ID:           record.ID,
			OldPath:      record.Path,
			NewPath:      destination,
			ExpectedHash: record.CurrentHash,
			Title:        rendered.Title,
		}); err != nil {
			moved = append(moved, pair)
			return rollback(err)
		}
		pair.dbUpdated = true
		moved = append(moved, pair)
	}

	if err := s.repo.MoveFolderRelated(ctx, oldPath, newPath); err != nil {
		return rollback(fmt.Errorf("move folder metadata: %w", err))
	}

	deepestOldDir := ""
	for _, pair := range moved {
		if dir := filepath.Dir(pair.oldFull); len(dir) > len(deepestOldDir) {
			deepestOldDir = dir
		}
	}
	if deepestOldDir != "" {
		removeEmptyDirsUpTo(s.sourceDir, deepestOldDir)
	}

	if s.onChange != nil {
		for _, pair := range moved {
			s.onChange(DocumentChange{
				Type:       "move",
				DocumentID: pair.record.ID,
				Path:       pair.newPath,
				OldPath:    pair.record.Path,
				Title:      pair.newTitle,
				Hash:       pair.record.CurrentHash,
			})
		}
	}
	return len(moved), nil
}

func (s *Service) ArchiveFolder(ctx context.Context, prefix string) (int, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, err := s.syncSourceDirLocked(ctx); err != nil {
		return 0, err
	}
	folder, err := NormalizeFolderPath(prefix)
	if err != nil {
		return 0, err
	}

	records, err := s.repo.ListDocuments(ctx)
	if err != nil {
		return 0, err
	}
	archived := make([]DocumentRecord, 0)
	for _, record := range records {
		if !strings.HasPrefix(record.Path, folder+"/") {
			continue
		}
		if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil {
			return len(archived), fmt.Errorf("archive document %s: %w", record.Path, err)
		}
		archived = append(archived, record)
	}
	if len(archived) == 0 {
		return 0, sql.ErrNoRows
	}

	if s.onChange != nil {
		for _, record := range archived {
			s.onChange(DocumentChange{
				DocumentID: record.ID,
				Path:       record.Path,
				Title:      record.Title,
				Hash:       record.CurrentHash,
			})
		}
	}
	return len(archived), nil
}

func removeEmptyDirsUpTo(root, dir string) {
	for dir != root && strings.HasPrefix(dir, root) {
		if err := os.Remove(dir); err != nil {
			return
		}
		dir = filepath.Dir(dir)
	}
}

func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
	if _, err := s.SyncSourceDir(ctx); err != nil {
		return nil, err
	}

	record, normalized, err := s.resolveRecord(ctx, requestPath)
	if err != nil {
		return nil, err
	}

	content, err := s.store.Read(record.CurrentHash)
	if err != nil {
		return nil, fmt.Errorf("read content %s: %w", record.CurrentHash, err)
	}

	rendered, err := s.renderer.RenderPath(content, normalized)
	if err != nil {
		return nil, fmt.Errorf("render page %s: %w", normalized, err)
	}

	return &Page{
		Path:  record.Path,
		Title: rendered.Title,
		Tags:  record.Tags,
		HTML:  string(rendered.HTML),
		Hash:  record.CurrentHash,
	}, nil
}

func (s *Service) LoadSourcePage(ctx context.Context, requestPath string) (*SourcePage, error) {
	if _, err := s.SyncSourceDir(ctx); err != nil {
		return nil, err
	}

	record, _, err := s.resolveRecord(ctx, requestPath)
	if err != nil {
		return nil, err
	}

	content, err := s.store.Read(record.CurrentHash)
	if err != nil {
		return nil, fmt.Errorf("read source content %s: %w", record.CurrentHash, err)
	}

	return &SourcePage{
		Path:    record.Path,
		Title:   record.Title,
		Tags:    record.Tags,
		Content: string(content),
		Hash:    record.CurrentHash,
	}, nil
}

func (s *Service) SaveSourcePage(ctx context.Context, requestPath string, content string) (*SourcePage, error) {
	return s.SaveSourcePageWithBaseHash(ctx, requestPath, content, "")
}

func (s *Service) SaveSourcePageWithBaseHash(ctx context.Context, requestPath string, content string, baseHash string) (*SourcePage, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, err := s.syncSourceDirLocked(ctx); err != nil {
		return nil, err
	}

	record, _, err := s.resolveRecord(ctx, requestPath)
	if err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			return nil, err
		}
		if baseHash != "" {
			return nil, err
		}
		path, pathErr := normalizeWritableDocumentPath(requestPath)
		if pathErr != nil {
			return nil, pathErr
		}
		record = &DocumentRecord{Path: path}
	}

	if baseHash != "" && record.CurrentHash != baseHash {
		currentContent, readErr := s.store.Read(record.CurrentHash)
		if readErr != nil {
			return nil, fmt.Errorf("read current content %s: %w", record.CurrentHash, readErr)
		}
		return nil, &DocumentConflictError{
			Path:           record.Path,
			BaseHash:       baseHash,
			CurrentHash:    record.CurrentHash,
			CurrentContent: string(currentContent),
		}
	}

	fullPath := filepath.Join(s.sourceDir, filepath.FromSlash(record.Path))
	if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
		return nil, fmt.Errorf("create document directory %s: %w", record.Path, err)
	}
	if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
		return nil, fmt.Errorf("write document %s: %w", record.Path, err)
	}

	change, err := s.syncFile(ctx, fullPath)
	if err != nil {
		return nil, err
	}
	if change != nil && s.onChange != nil {
		s.onChange(*change)
	}

	updated, _, err := s.resolveRecord(ctx, record.Path)
	if err != nil {
		return nil, err
	}

	return &SourcePage{
		Path:    updated.Path,
		Title:   updated.Title,
		Tags:    updated.Tags,
		Content: content,
		Hash:    updated.CurrentHash,
	}, nil
}

func (s *Service) resolveRecord(ctx context.Context, requestPath string) (*DocumentRecord, string, error) {

	var (
		record     *DocumentRecord
		normalized string
		lastErr    error
	)
	for _, candidate := range normalizeRequestPathCandidates(requestPath) {
		found, err := s.repo.GetDocumentByPathOrAlias(ctx, candidate)
		if err == nil {
			record = found
			normalized = found.Path
			break
		}
		lastErr = err
	}
	if record == nil {
		return nil, "", lastErr
	}

	return record, normalized, nil
}

func normalizeRequestPathCandidates(path string) []string {
	path = strings.Trim(path, "/")
	if path == "" {
		return []string{"index.md", "getting-started.md"}
	}
	if !strings.HasSuffix(path, ".md") {
		return []string{path + ".md", path + "/index.md"}
	}
	return []string{path}
}

func normalizeWritableDocumentPath(path string) (string, error) {
	path = strings.Trim(path, "/")
	if path == "" {
		return "", fmt.Errorf("document path is required")
	}
	path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
	if path == "." || strings.HasPrefix(path, "../") || path == ".." || filepath.IsAbs(path) {
		return "", fmt.Errorf("invalid document path")
	}
	if !strings.HasSuffix(strings.ToLower(path), ".md") {
		path += ".md"
	}
	return path, nil
}

func NormalizeDocumentPath(path string) (string, error) {
	return normalizeWritableDocumentPath(path)
}

func NormalizeFolderPath(path string) (string, error) {
	path = strings.Trim(path, "/")
	if path == "" {
		return "", fmt.Errorf("folder path is required")
	}
	path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
	if path == "." || path == ".." || strings.HasPrefix(path, "../") || filepath.IsAbs(path) {
		return "", fmt.Errorf("invalid folder path")
	}
	for _, segment := range strings.Split(path, "/") {
		if segment == "" || segment == "." || segment == ".." {
			return "", fmt.Errorf("invalid folder path")
		}
		if strings.HasSuffix(strings.ToLower(segment), ".md") {
			return "", fmt.Errorf("folder names must not end with .md")
		}
	}
	return path, nil
}

func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
	content, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read content file %s: %w", path, err)
	}

	relative, err := filepath.Rel(s.sourceDir, path)
	if err != nil {
		return nil, fmt.Errorf("compute relative path %s: %w", path, err)
	}
	relative = filepath.ToSlash(relative)

	rendered, err := s.renderer.RenderPath(content, relative)
	if err != nil {
		return nil, fmt.Errorf("render content file %s: %w", path, err)
	}

	record, err := s.store.PutBytes(content)
	if err != nil {
		return nil, fmt.Errorf("store content file %s: %w", path, err)
	}

	existing, err := s.repo.GetDocumentByPathIncludingArchived(ctx, relative)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return nil, err
	}

	previousHash := ""
	documentID := "doc:" + strings.TrimSuffix(relative, ".md")
	if existing != nil {
		previousHash = existing.CurrentHash
		documentID = existing.ID
		if existing.CurrentHash == record.Hash {
			return nil, nil
		}
	}

	if err := s.repo.PersistDocument(ctx, PersistDocumentInput{
		ID:           documentID,
		Path:         relative,
		Title:        rendered.Title,
		Hash:         record.Hash,
		PreviousHash: previousHash,
		Tags:         rendered.Tags,
	}); err != nil {
		return nil, err
	}

	if err := s.repo.UpdateSearchContent(ctx, relative, string(content)); err != nil {
		s.logger.Warn("index search content", "path", relative, "error", err)
	}

	if existing != nil && existing.ArchivedAt != nil {
		return nil, nil
	}

	s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
	return &DocumentChange{
		DocumentID:   documentID,
		Path:         relative,
		Title:        rendered.Title,
		Hash:         record.Hash,
		PreviousHash: previousHash,
	}, nil
}
