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 {
	Path         string `json:"path"`
	Title        string `json:"title"`
	Hash         string `json:"hash"`
	PreviousHash string `json:"previousHash,omitempty"`
}

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{
			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) 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.GetDocumentByPath(ctx, candidate)
		if err == nil {
			record = found
			normalized = candidate
			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 (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{
		Path:         relative,
		Title:        rendered.Title,
		Hash:         record.Hash,
		PreviousHash: previousHash,
	}, nil
}
