package pushnotify

import (
	"context"
	"errors"
	"fmt"
	"log/slog"

	"github.com/flowy-live/llink/internal/network"
)

type NotifyInput struct {
	NetworkID         string
	SenderHumanID     string
	SenderEmailPrefix string

	ParticleID string
	// One of "text", "media", "file", "quest", "paper". Containers (stream,
	// folder) are dropped by the caller before reaching the notifier.
	ParticleKind string

	// Parent stream context — drives the title and the recipient set.
	StreamID        string
	StreamName      string
	StreamVisibleTo []string

	// Body — already formatted by the caller (e.g. truncated text, "Sent a
	// voice message"). Title is derived inside the notifier.
	Body string
}

// Notifier fans out one particle to Expo:
//  1. Resolve recipients (visibility ∩ network members, minus sender).
//  2. Send a batched Expo request for every recipient's tokens.
//  3. Prune tokens Expo reports as DeviceNotRegistered.
//
// Online/offline presence is intentionally NOT consulted: a live WebSocket
// is a poor proxy for "user is actively consuming this particle right now"
// (backgrounded apps, idle desktops, etc. all look online), and the resulting
// false-negatives outweigh the duplicate-notification cost on a focused
// device, which the OS handles via Focus modes and per-app settings.
type Notifier struct {
	networkR network.Reader
	tokens   Service
	expo     *ExpoClient
}

func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier {
	return &Notifier{
		networkR: networkR,
		tokens:   tokens,
		expo:     expo,
	}
}

func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
	if in.NetworkID == "" || in.ParticleID == "" {
		slog.Info("pushnotify: skip — missing ids",
			"networkID", in.NetworkID,
			"particleID", in.ParticleID,
		)
		return nil
	}

	members, err := n.networkR.ListMembers(ctx, in.NetworkID)
	if err != nil {
		return fmt.Errorf("list network members: %w", err)
	}

	recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
	recipientsBeforeSenderFilter := len(recipients)
	recipients = filterOut(recipients, in.SenderHumanID)
	if len(recipients) == 0 {
		slog.Info("pushnotify: skip — no recipients",
			"networkID", in.NetworkID,
			"particleID", in.ParticleID,
			"senderHumanID", in.SenderHumanID,
			"members", len(members),
			"visibleTo", in.StreamVisibleTo,
			"resolved", recipientsBeforeSenderFilter,
		)
		return nil
	}

	tokens, err := n.tokens.ListForHumans(ctx, recipients)
	if err != nil {
		return fmt.Errorf("token lookup: %w", err)
	}
	if len(tokens) == 0 {
		slog.Info("pushnotify: skip — no tokens for recipients",
			"networkID", in.NetworkID,
			"particleID", in.ParticleID,
			"recipients", len(recipients),
			"recipientIDs", recipients,
		)
		return nil
	}

	msgs := buildMessages(tokens, in)
	tickets, sendErr := n.expo.Send(ctx, msgs)
	slog.Info("pushnotify: dispatch",
		"networkID", in.NetworkID,
		"particleID", in.ParticleID,
		"recipients", len(recipients),
		"tokens", len(tokens),
		"sent", len(tickets),
	)

	n.cleanupDeadTokens(ctx, msgs, tickets)

	if sendErr != nil {
		return fmt.Errorf("expo send: %w", sendErr)
	}
	return nil
}

// DeviceNotRegistered is the one feedback signal we honor; other ticket
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
	for i, t := range tickets {
		if i >= len(msgs) {
			break
		}
		if t.Status != "error" || t.Details == nil {
			continue
		}
		code, _ := t.Details["error"].(string)
		if code != ExpoErrorDeviceNotRegistered {
			if t.Status == "error" {
				slog.Warn("pushnotify: ticket error", "code", code, "message", t.Message, "to", msgs[i].To)
			}
			continue
		}
		if err := n.tokens.DeleteByToken(ctx, msgs[i].To); err != nil && !errors.Is(err, ErrNotFound) {
			slog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To)
		} else {
			slog.Info("pushnotify: removed unregistered token", "token", msgs[i].To)
		}
	}
}

func buildMessages(tokens []*PushToken, in NotifyInput) []Message {
	title := in.SenderEmailPrefix
	if in.StreamName != "" {
		title = in.SenderEmailPrefix + " in " + in.StreamName
	}

	data := map[string]any{
		"kind":            "particle_created",
		"network_id":      in.NetworkID,
		"stream_id":       in.StreamID,
		"particle_id":     in.ParticleID,
		"sender_human_id": in.SenderHumanID,
		"particle_kind":   in.ParticleKind,
	}

	msgs := make([]Message, 0, len(tokens))
	for _, t := range tokens {
		msgs = append(msgs, Message{
			To:    t.Token,
			Title: title,
			Body:  in.Body,
			Data:  data,
		})
	}
	return msgs
}

func filterOut(ids []string, exclude string) []string {
	if exclude == "" {
		return ids
	}
	out := ids[:0:len(ids)]
	for _, id := range ids {
		if id != exclude {
			out = append(out, id)
		}
	}
	return out
}
