package network

import (
	"context"
	"errors"
	"fmt"
	"html"
	"slices"
	"strings"

	"github.com/flowy-live/llink/internal/utils/flog"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	pbaero "github.com/flowy-live/llink/genproto/aero"
	"github.com/flowy-live/llink/internal/billing"
	"github.com/flowy-live/llink/internal/constants"
	"github.com/flowy-live/llink/internal/human"
	"github.com/flowy-live/llink/internal/livestore"
	"github.com/flowy-live/llink/internal/utils"
)

var ErrNotFound = errors.New("network not found")
var ErrInvalidName = errors.New("name cannot be empty")
var ErrInvalidEmail = errors.New("invalid email")
var ErrInvalidHumanId = errors.New("invalid humanId")
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336")

type humanLookup interface {
	GetByID(ctx context.Context, id string) (*human.Human, error)
}

type Service interface {
	Reader

	// Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
	Create(ctx context.Context, name, adminHumanId string) (*Network, error)
	// SetName returns ErrNotFound or ErrInvalidName.
	SetName(ctx context.Context, id, name string) error
	// AddMembers inserts members and syncs the new seat count to billing
	// atomically; a Stripe failure rolls the insert back.
	// Returns ErrInvalidHumanId if any humanId is empty.
	AddMembers(ctx context.Context, networkID string, humanIds []string) error
	// RemoveMember returns ErrInvalidHumanId if humanId is empty.
	RemoveMember(ctx context.Context, networkID, humanId string) error

	// Invitations (email-based, for users who haven't registered yet)

	// InviteByEmail returns ErrNotFound if the network doesn't exist
	// or ErrInvalidEmail if any email fails normalization.
	InviteByEmail(ctx context.Context, networkID string, emails []string) error
	// AcceptInvitation returns ErrInvalidEmail or ErrInvalidHumanId.
	AcceptInvitation(ctx context.Context, networkID, email, humanId string) error
	// RevokeInvitation returns ErrInvalidEmail.
	RevokeInvitation(ctx context.Context, networkID, email string) error
}

type serviceImpl struct {
	*readerImpl
	aeroSvc     pbaero.PrimaryClient
	billingSvc  billing.Service
	pub         livestore.MembershipPublisher
	humanLookup humanLookup
}

func NewService(
	pool *pgxpool.Pool,
	aeroSvc pbaero.PrimaryClient,
	billingSvc billing.Service,
	pub livestore.MembershipPublisher,
	humanLookup humanLookup,
) Service {
	return &serviceImpl{
		readerImpl:  newReader(pool),
		aeroSvc:     aeroSvc,
		billingSvc:  billingSvc,
		pub:         pub,
		humanLookup: humanLookup,
	}
}

func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		return nil, ErrInvalidName
	}

	network, err := s.repo.create(ctx, name, adminHumanId)
	if err != nil {
		return nil, err
	}

	if err := s.AddMembers(ctx, network.ID, []string{adminHumanId}); err != nil {
		return nil, err
	}

	_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
		ToEmails: []string{constants.FlowyAdminEmail},
		Subject:  fmt.Sprintf("New network created: %s", network.Name),
		TemplateData: &pbaero.ShootEmailRequest_GenericFlowyAdminAlertData{
			GenericFlowyAdminAlertData: &pbaero.GenericFlowyAdminAlertData{
				Message: "This is a simple notification that a new network was created. Please attend to them.",
			},
		},
	})
	if err != nil {
		flog.Warn("unable to send admin update email", "error", err)
	}

	return network, nil
}

func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
	name = strings.TrimSpace(name)
	if name == "" {
		return ErrInvalidName
	}

	err := s.repo.updateName(ctx, id, name)
	if errors.Is(err, errNotFound) {
		return ErrNotFound
	}
	return err
}

func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error {
	if slices.Contains(humanIds, "") {
		return ErrInvalidHumanId
	}
	if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
		for _, humanId := range humanIds {
			if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
				return err
			}
		}
		return nil
	}); err != nil {
		return err
	}

	for _, humanId := range humanIds {
		s.mirrorAddMembership(ctx, humanId, networkID)
	}
	return nil
}

func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
	if humanId == "" {
		return ErrInvalidHumanId
	}
	if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
		return s.repo.removeMember(ctx, tx, networkID, humanId)
	}); err != nil {
		return err
	}

	s.mirrorRemoveMembership(ctx, humanId, networkID)
	return nil
}

// Mirror the live store membership projection (humans/{humanId}.networks).
// Postgres is the source of truth: failures are logged and the reconciler heals drift.
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
	if err := s.pub.Add(ctx, humanId, networkID); err != nil {
		flog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID)
	}
}

func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, networkID string) {
	if err := s.pub.Remove(ctx, humanId, networkID); err != nil {
		flog.Error("membership publish remove failed", "error", err, "humanId", humanId, "networkID", networkID)
	}
}

// Runs fn in a tx and syncs seats to billing atomically. Any error rolls back.
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback(ctx)

	if err := fn(tx); err != nil {
		return err
	}

	seats, err := s.repo.countSeats(ctx, tx, networkID)
	if err != nil {
		return fmt.Errorf("count seats: %w", err)
	}

	if err := s.billingSvc.SyncSeats(ctx, networkID, seats); err != nil {
		return fmt.Errorf("sync billing seats: %w", err)
	}

	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("commit tx: %w", err)
	}
	return nil
}

func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
	network, err := s.repo.getByID(ctx, networkID)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	for _, email := range emails {
		normalized, err := utils.NormalizeEmail(email)
		if err != nil {
			return fmt.Errorf("%q: %w: %w", email, ErrInvalidEmail, err)
		}
		if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil {
			return err
		}

		_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
			ToEmails: []string{email},
			Subject:  fmt.Sprintf("Invitation to Join %s on Flowy.llink", network.Name),
			TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
				SimpleHtmlData: &pbaero.SimpleHtmlData{
					Html: buildInvitationHTML(network.Name),
				},
			},
		})
		if err != nil {
			flog.Warn("unable to send email notification", "email", email, "network", network.Name)
		}
	}
	return nil
}

func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error {
	network, err := s.repo.getByID(ctx, networkID)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	normalized, err := utils.NormalizeEmail(email)
	if err != nil {
		return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
	}
	if humanId == "" {
		return ErrInvalidHumanId
	}

	prevMembersHumanIds, membersErr := s.repo.getMemberHumanIds(ctx, networkID)
	if membersErr != nil {
		flog.Warn("unable to get member human ids", "error", membersErr)
	}

	if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
		err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
		if err != nil {
			return err
		}
		return s.repo.addMember(ctx, tx, networkID, humanId)
	}); err != nil {
		return err
	}

	if membersErr == nil && len(prevMembersHumanIds) > 0 {
		emailRecipients := make([]string, 0, len(prevMembersHumanIds))
		for _, memberHumanId := range prevMembersHumanIds {
			if memberHumanId != "" {
				human, err := s.humanLookup.GetByID(ctx, memberHumanId)
				if err != nil {
					flog.Warn("unable to find human", "error", err, "humanId", memberHumanId)
					continue
				}

				if human.Email == "" || !human.EmailNotificationsEnabled {
					continue
				}
				emailRecipients = append(emailRecipients, human.Email)
			}
		}

		if len(emailRecipients) > 0 {
			newMemberEmailPrefix := strings.Split(normalized, "@")[0]
			_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
				ToEmails: emailRecipients,
				Subject:  fmt.Sprintf("A new member has joined %s", network.Name),
				TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
					SimpleHtmlData: &pbaero.SimpleHtmlData{
						Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix),
					},
				},
			})
			if err != nil {
				flog.Warn("unable to send email notification", "email", email, "network", network.Name)
			}
		}
	}

	s.mirrorAddMembership(ctx, humanId, networkID)
	return nil
}

func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
	normalized, err := utils.NormalizeEmail(email)
	if err != nil {
		return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
	}
	return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
}

const (
	emailWebAppURL   = "https://llink.flowy.live"
	emailDesktopURL  = "llink://"
	emailDownloadURL = "https://flowylabs.ai/llink/download"
)

// emailDesktopFooter is the shared secondary line offering the desktop app.
// The web app is always the primary CTA (no install required), so desktop is
// kept quiet here and shared across templates so the two can't drift apart.
func emailDesktopFooter() string {
	return fmt.Sprintf(`<tr>
            <td style="font-size:13px;color:#888888;">
              Prefer the desktop app? <a href="%s" style="color:#111111;">Open it</a> or <a href="%s" style="color:#111111;">download here</a>.
            </td>
          </tr>`, emailDesktopURL, emailDownloadURL)
}

func buildInvitationHTML(networkName string) string {
	safeName := html.EscapeString(networkName)
	return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
    <tr>
      <td align="center">
        <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
          <tr>
            <td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
              You&#39;ve been invited to join %s on Flowy.llink
            </td>
          </tr>
          <tr>
            <td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
              Open the app to accept your invitation. If you&#39;re new, you&#39;ll be prompted to create a free account first.
            </td>
          </tr>
          <tr>
            <td style="padding-bottom:24px;">
              <a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
                Open Flowy.llink (web)
              </a>
            </td>
          </tr>
          %s
        </table>
      </td>
    </tr>
  </table>
</body>
</html>`, safeName, emailWebAppURL, emailDesktopFooter())
}

// buildNewMemberHTML is an email template to notify other members that a new member has joined
func buildNewMemberHTML(networkName, newMemberEmailPrefix string) string {
	safeName := html.EscapeString(networkName)
	safeEmail := html.EscapeString(newMemberEmailPrefix)
	return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
    <tr>
      <td align="center">
        <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
          <tr>
            <td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
              A new member joined %s
            </td>
          </tr>
          <tr>
            <td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
              <strong style="color:#111111;">%s</strong> just joined your network on Flowy.llink. Say hello and bring them up to speed.
            </td>
          </tr>
          <tr>
            <td style="padding-bottom:24px;">
              <a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
                Open Flowy.llink (web)
              </a>
            </td>
          </tr>
          %s
        </table>
      </td>
    </tr>
  </table>
</body>
</html>`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter())
}
