package collaboration

import (
	"context"
	"fmt"
	"log/slog"
	"net/url"
	"strings"
	"time"

	"github.com/tim/cairnquire/apps/server/internal/auth"
	"github.com/tim/cairnquire/apps/server/internal/realtime"
)

// EmailSender mirrors email.Sender without taking a direct dependency on the
// email package (which would create a cycle once the queue imports from
// collaboration's repository types).
type EmailSender interface {
	Send(ctx context.Context, to []string, subject, body string) error
}

// EmailQueue is the minimal contract the collaboration service needs to
// enqueue an outgoing email. The email.Queue type satisfies this.
type EmailQueue interface {
	Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error)
}

// EmailRenderer renders a plain-text email for a notification kind. The
// email.Render function satisfies this.
type EmailRenderer interface {
	Render(kind string, data EmailData) (subject, body string, err error)
}

// EmailData is the email-package TemplateData projected through the
// collaboration boundary to avoid an import cycle.
type EmailData struct {
	ActorName     string
	ActorEmail    string
	DocumentPath  string
	DocumentTitle string
	CommentBody   string
	MentionText   string
	ConflictDesc  string
	ViewURL       string
	UnsubURL      string
	InstanceName  string
}

// UserLookup resolves a user id to an email address and display name. The
// auth.Repository satisfies this.
type UserLookup interface {
	GetUserByID(ctx context.Context, userID string) (auth.User, error)
}

type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error)

type NoOpEmailSender struct{}

func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
	slog.Info("email stub", "to", to, "subject", subject, "body_len", len(body))
	return nil
}

type Service struct {
	repo            *Repository
	email           EmailSender
	queue           EmailQueue
	renderer        EmailRenderer
	users           UserLookup
	canReadDocument DocumentReadAuthorizer
	hub             *realtime.Hub
	logger          *slog.Logger
	publicOrigin    string
}

type Options struct {
	EmailSender     EmailSender
	EmailQueue      EmailQueue
	EmailRenderer   EmailRenderer
	UserLookup      UserLookup
	CanReadDocument DocumentReadAuthorizer
	PublicOrigin    string
}

func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
	return NewServiceWithOptions(repo, hub, logger, Options{EmailSender: email})
}

// NewServiceWithOptions constructs the service with email queue + renderer +
// user lookup wiring. Callers that want email delivery should pass all of
// EmailQueue, EmailRenderer, UserLookup; otherwise notifications stay
// in-app only.
func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Logger, opts Options) *Service {
	if opts.EmailSender == nil {
		opts.EmailSender = &NoOpEmailSender{}
	}
	return &Service{
		repo:            repo,
		email:           opts.EmailSender,
		queue:           opts.EmailQueue,
		renderer:        opts.EmailRenderer,
		users:           opts.UserLookup,
		canReadDocument: opts.CanReadDocument,
		hub:             hub,
		logger:          logger,
		publicOrigin:    opts.PublicOrigin,
	}
}

// Comments

func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, input CreateCommentInput) (*Comment, error) {
	if principal.UserID == "" {
		return nil, fmt.Errorf("authentication required")
	}
	input.Content = strings.TrimSpace(input.Content)
	if input.Content == "" {
		return nil, fmt.Errorf("content is required")
	}
	if input.DocumentID == "" {
		return nil, fmt.Errorf("document_id is required")
	}
	if input.VersionHash == "" {
		return nil, fmt.Errorf("version_hash is required")
	}

	if input.ParentID != nil && *input.ParentID != "" {
		parent, err := s.repo.GetComment(ctx, *input.ParentID)
		if err != nil {
			return nil, fmt.Errorf("parent comment not found")
		}
		if parent.DocumentID != input.DocumentID {
			return nil, fmt.Errorf("parent comment belongs to another document")
		}
		if parent.ResolvedAt != nil {
			return nil, fmt.Errorf("cannot reply to a resolved thread")
		}
		if input.AnchorHash == nil {
			input.AnchorHash = parent.AnchorHash
		}
		if input.AnchorLine == nil {
			input.AnchorLine = parent.AnchorLine
		}
	}

	comment := Comment{
		ID:          randomID("comment"),
		DocumentID:  input.DocumentID,
		VersionHash: input.VersionHash,
		AuthorID:    principal.UserID,
		Content:     input.Content,
		CreatedAt:   time.Now().UTC(),
	}
	if input.ParentID != nil && *input.ParentID != "" {
		comment.ParentID = input.ParentID
	}
	if input.AnchorHash != nil && *input.AnchorHash != "" {
		comment.AnchorHash = input.AnchorHash
	}
	if input.AnchorLine != nil && *input.AnchorLine > 0 {
		comment.AnchorLine = input.AnchorLine
	}

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

	created, err := s.repo.GetComment(ctx, comment.ID)
	if err != nil {
		return nil, err
	}

	if err := s.notifyWatchersOfComment(ctx, *created); err != nil {
		s.logger.Warn("failed to notify watchers of comment", "error", err)
	}

	// Broadcast to connected clients
	s.broadcastNotification(principal.UserID)

	return created, nil
}

func (s *Service) ListComments(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
	return s.repo.ListCommentsByDocument(ctx, documentID, includeResolved)
}

func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
	return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash, includeResolved)
}

func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) {
	return s.repo.GetComment(ctx, commentID)
}

func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error {
	if principal.UserID == "" {
		return fmt.Errorf("authentication required")
	}
	comment, err := s.repo.GetComment(ctx, commentID)
	if err != nil {
		return fmt.Errorf("comment not found")
	}
	if comment.ParentID != nil {
		return fmt.Errorf("only a thread root can be resolved")
	}
	return s.repo.ResolveCommentThread(ctx, commentID, principal.UserID)
}

// Notifications

func (s *Service) ListNotifications(ctx context.Context, principal auth.Principal, unreadOnly bool, limit int) ([]Notification, error) {
	if principal.UserID == "" {
		return nil, fmt.Errorf("authentication required")
	}
	return s.repo.ListNotifications(ctx, principal.UserID, unreadOnly, limit)
}

func (s *Service) GetBellStatus(ctx context.Context, principal auth.Principal) (*NotificationBell, error) {
	if principal.UserID == "" {
		return &NotificationBell{UnreadCount: 0, Items: []Notification{}}, nil
	}
	count, err := s.repo.CountUnreadNotifications(ctx, principal.UserID)
	if err != nil {
		return nil, err
	}
	items, err := s.repo.ListNotifications(ctx, principal.UserID, false, 20)
	if err != nil {
		return nil, err
	}
	return &NotificationBell{
		UnreadCount: count,
		Items:       items,
	}, nil
}

func (s *Service) MarkNotificationRead(ctx context.Context, principal auth.Principal, notificationID string) error {
	if principal.UserID == "" {
		return fmt.Errorf("authentication required")
	}
	return s.repo.MarkNotificationRead(ctx, notificationID, principal.UserID)
}

func (s *Service) MarkAllNotificationsRead(ctx context.Context, principal auth.Principal) error {
	if principal.UserID == "" {
		return fmt.Errorf("authentication required")
	}
	return s.repo.MarkAllNotificationsRead(ctx, principal.UserID)
}

// Watchers

func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, documentID string) error {
	if principal.UserID == "" {
		return fmt.Errorf("authentication required")
	}
	if strings.TrimSpace(documentID) == "" {
		return fmt.Errorf("document_id is required")
	}
	return s.repo.AddWatcher(ctx, Watcher{
		UserID:     principal.UserID,
		DocumentID: &documentID,
		CreatedAt:  time.Now().UTC(),
	})
}

func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal, documentID string) error {
	if principal.UserID == "" {
		return fmt.Errorf("authentication required")
	}
	if strings.TrimSpace(documentID) == "" {
		return fmt.Errorf("document_id is required")
	}
	return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
}

func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error {
	if principal.UserID == "" {
		return fmt.Errorf("authentication required")
	}
	folderPath, err := NormalizeFolderPath(folderPath)
	if err != nil {
		return err
	}
	return s.repo.AddWatcher(ctx, Watcher{
		UserID:     principal.UserID,
		FolderPath: &folderPath,
		CreatedAt:  time.Now().UTC(),
	})
}

func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error {
	if principal.UserID == "" {
		return fmt.Errorf("authentication required")
	}
	folderPath, err := NormalizeFolderPath(folderPath)
	if err != nil {
		return err
	}
	return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
}

func (s *Service) IsWatching(ctx context.Context, principal auth.Principal, documentID string) (bool, error) {
	if principal.UserID == "" {
		return false, nil
	}
	return s.repo.IsWatching(ctx, principal.UserID, documentID)
}

// Document change notifications

func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, documentPath string, actorName string) error {
	watchers, err := s.repo.ListWatchersByDocument(ctx, documentID)
	if err != nil {
		return fmt.Errorf("list document watchers: %w", err)
	}
	folderWatchers, err := s.repo.ListWatchersByFolder(ctx, documentPath)
	if err != nil {
		return fmt.Errorf("list folder watchers: %w", err)
	}

	seen := make(map[string]bool)
	for _, w := range append(watchers, folderWatchers...) {
		if seen[w.UserID] {
			continue
		}
		seen[w.UserID] = true
		if !s.watcherCanRead(ctx, w.UserID, documentID) {
			continue
		}

		msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
		notification := Notification{
			ID:           randomID("notif"),
			UserID:       w.UserID,
			Type:         "file_changed",
			ResourceType: "document",
			ResourceID:   documentID,
			Message:      msg,
			CreatedAt:    time.Now().UTC(),
		}
		if err := s.repo.CreateNotification(ctx, notification); err != nil {
			s.logger.Warn("create notification", "error", err)
			continue
		}
		s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{
			ActorName:    actorName,
			DocumentPath: documentPath,
			ViewURL:      s.documentURL(documentPath),
		})
	}

	// Broadcast bell update to all connected clients
	s.broadcastNotification("")

	return nil
}

func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
	watchers, err := s.repo.ListWatchersForDocument(ctx, comment.DocumentID)
	if err != nil {
		return err
	}

	seen := make(map[string]bool)
	for _, w := range watchers {
		if w.UserID == comment.AuthorID {
			continue
		}
		if seen[w.UserID] {
			continue
		}
		seen[w.UserID] = true
		if !s.watcherCanRead(ctx, w.UserID, comment.DocumentID) {
			continue
		}
		msg := fmt.Sprintf("New comment on document")
		if comment.AnchorHash != nil {
			msg = fmt.Sprintf("New comment on a section of the document")
		}
		notification := Notification{
			ID:           randomID("notif"),
			UserID:       w.UserID,
			Type:         "comment",
			ResourceType: "document",
			ResourceID:   comment.DocumentID,
			Message:      msg,
			CreatedAt:    time.Now().UTC(),
		}
		if err := s.repo.CreateNotification(ctx, notification); err != nil {
			s.logger.Warn("create comment notification", "error", err)
			continue
		}
		s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
			ActorName:    comment.AuthorName,
			DocumentPath: documentPathFromID(comment.DocumentID) + ".md",
			CommentBody:  comment.Content,
			ViewURL:      s.documentURL(comment.DocumentID),
		})
	}
	return nil
}

// enqueueNotificationEmail resolves the watcher's email address, renders the
// template, and enqueues a durable email. Failures are logged and swallowed
// so a bad email config never blocks the in-app notification path.
func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID, userID, kind string, data EmailData) {
	if s.queue == nil || s.renderer == nil || s.users == nil {
		return
	}
	user, err := s.users.GetUserByID(ctx, userID)
	if err != nil {
		s.logger.Warn("email: lookup user", "user_id", userID, "error", err)
		return
	}
	if user.Email == "" || user.Disabled {
		return
	}
	if data.ActorName == "" {
		data.ActorName = "Someone"
	}
	subject, body, err := s.renderer.Render(kind, data)
	if err != nil {
		s.logger.Warn("email: render", "kind", kind, "error", err)
		return
	}
	if _, err := s.queue.Enqueue(ctx, notificationID, user.Email, subject, body); err != nil {
		s.logger.Warn("email: enqueue", "user_id", userID, "error", err)
	}
}

func (s *Service) documentURL(documentPath string) string {
	if s.publicOrigin == "" {
		return ""
	}
	clean := strings.TrimPrefix(documentPath, "doc:")
	clean = strings.TrimSuffix(strings.TrimPrefix(clean, "/"), ".md")
	parts := strings.Split(clean, "/")
	for i, part := range parts {
		parts[i] = url.PathEscape(part)
	}
	return strings.TrimRight(s.publicOrigin, "/") + "/docs/" + strings.Join(parts, "/")
}

func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool {
	if s.canReadDocument == nil {
		return true
	}
	allowed, err := s.canReadDocument(ctx, userID, documentID)
	if err != nil {
		s.logger.Warn("authorize watcher", "user_id", userID, "document_id", documentID, "error", err)
		return false
	}
	return allowed
}

func (s *Service) broadcastNotification(userID string) {
	if s.hub == nil {
		return
	}
	s.hub.Broadcast(realtime.Event{
		Type: "notification",
		Data: map[string]string{"userId": userID},
	})
}

func (s *Service) SetEmailSender(email EmailSender) {
	if email != nil {
		s.email = email
	}
}
