package particle

import (
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"time"

	"github.com/flowy-live/llink/internal/network"
	"github.com/flowy-live/llink/internal/utils"
	"github.com/jackc/pgx/v5/pgxpool"
)

const defaultPageSize = 50

type Service interface {
	// Create creates a new particle. Caller must be a network member (verified by handler).
	// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded.
	Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
	// GetByID returns ErrNotFound or ErrAccessDenied.
	GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
	// Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
	Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
	// Delete returns ErrNotFound or ErrAccessDenied.
	Delete(ctx context.Context, id, requesterEmail string) error

	// List returns particles in a network. Use parentID=nil for root particles.
	// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
	List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)

	// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
	OpenStream(ctx context.Context, id, requesterEmail string) error
	// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed.
	CloseStream(ctx context.Context, id, requesterEmail string) error

	// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
	SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
	// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
	AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
	// RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
	RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error

	// Seen tracking (private)
	MarkSeen(ctx context.Context, id, requesterEmail string) error
	MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error

	// Ack tracking (public, permanent)
	Ack(ctx context.Context, id, requesterEmail string) error

	// Unseen counts for stream list view
	GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)

	// Bulk lookups for handler enrichment
	GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
	GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
	GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
}

type serviceImpl struct {
	repo       repository
	networkSvc network.Service
}

func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service {
	return &serviceImpl{
		repo:       newRepository(pool),
		networkSvc: networkSvc,
	}
}

// checkAccess verifies that the email has access to the particle based on visibility.
// Assumes the caller is already verified as a network member (handler responsibility).
// Walks up the ancestor chain only when visibility is inherited, stopping at the first
// network_all or custom node.
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
	ancestors, err := s.repo.getAncestorChain(ctx, particleID)
	if err != nil {
		return false, err
	}

	if len(ancestors) == 0 {
		return false, errNotFound
	}

	// Build lookup map by ID
	byID := make(map[string]*Particle, len(ancestors))
	for _, p := range ancestors {
		byID[p.ID] = p
	}

	// Start from the target particle (first in chain) and walk up on inherited
	current := ancestors[0]
	for {
		switch current.Visibility {
		case VisibilityNetworkAll:
			return true, nil
		case VisibilityCustom:
			return s.repo.isMemberOf(ctx, current.ID, email)
		case VisibilityInherited:
			if current.ParentID == nil {
				// inherited at root is invalid state, deny access
				return false, nil
			}
			parent, ok := byID[*current.ParentID]
			if !ok {
				return false, nil
			}
			current = parent
		default:
			return false, nil
		}
	}
}

func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error) {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return nil, err
	}

	// Validate particle type
	if !isValidParticleType(input.Type) {
		return nil, ErrInvalidType
	}

	// Validate data matches type requirements
	if err := validateParticleData(input.Type, input.Data); err != nil {
		return nil, err
	}

	// MVP visibility rules:
	// - Child particles (have parent) → always inherited
	// - Root particles (no parent) → cannot be inherited, default network_all
	if input.ParentID != nil {
		// Children always inherit from parent
		input.Visibility = VisibilityInherited
		input.Members = nil // no members on inherited particles

		// Reject streams and folders as children (MVP: streams are root-level only)
		if input.Type == TypeStream || input.Type == TypeFolder {
			return nil, ErrInvalidParent
		}
	} else {
		// Root particles cannot be inherited
		if input.Visibility == VisibilityInherited {
			return nil, ErrInheritedAtRoot
		}
		if input.Visibility == "" {
			input.Visibility = VisibilityNetworkAll
		}
	}

	// Custom visibility requires at least one member and must be a stream
	if input.Visibility == VisibilityCustom {
		if input.Type != TypeStream {
			return nil, ErrNotAContainer
		}
		if len(input.Members) == 0 {
			return nil, ErrMembersRequired
		}
	}

	// Network membership is verified by handler - we only check particle visibility
	// If parent specified, check parent access (visibility-based)
	if input.ParentID != nil {
		hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
		if err != nil {
			if errors.Is(err, errNotFound) {
				return nil, ErrInvalidParent
			}
			return nil, err
		}
		if !hasAccess {
			return nil, ErrAccessDenied
		}
	}

	// Build the particle
	p := &Particle{
		Type:           input.Type,
		NetworkID:      input.NetworkID,
		ParentID:       input.ParentID,
		CreatedByEmail: requesterEmail,
		Visibility:     input.Visibility,
		Data:           input.Data,
	}

	if p.Data == nil {
		p.Data = json.RawMessage("{}")
	}

	// For streams, set initial status to open and check capacity
	if input.Type == TypeStream {
		// Set status to open in the data JSON
		data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
		if err != nil {
			return nil, err
		}
		p.Data = data

		// Check and increment capacity
		err = s.networkSvc.IncrementOpenStreamCount(ctx, input.NetworkID)
		if err != nil {
			if errors.Is(err, network.ErrCapacityExceeded) {
				return nil, ErrCapacityExceeded
			}
			return nil, err
		}
	}

	// Create the particle
	created, err := s.repo.create(ctx, p)
	if err != nil {
		// If we incremented the stream count but creation failed, decrement it
		if input.Type == TypeStream {
			if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, input.NetworkID); decErr != nil {
				slog.Warn("failed to rollback stream count after particle creation failure", "error", decErr, "network_id", input.NetworkID)
			}
		}
		return nil, err
	}

	// Add members if custom visibility (only streams for MVP)
	if input.Visibility == VisibilityCustom && len(input.Members) > 0 {
		normalizedEmails := make([]string, 0, len(input.Members)+1)
		// Always include the creator
		normalizedEmails = append(normalizedEmails, requesterEmail)
		for _, email := range input.Members {
			normalized, err := utils.NormalizeEmail(email)
			if err != nil {
				continue // Skip invalid emails
			}
			if normalized == requesterEmail {
				continue // Already added
			}
			normalizedEmails = append(normalizedEmails, normalized)
		}
		if err := s.repo.addMembers(ctx, created.ID, normalizedEmails); err != nil {
			return nil, err
		}
	}

	return created, nil
}

func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error) {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return nil, err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return nil, ErrNotFound
		}
		return nil, err
	}
	if !hasAccess {
		return nil, ErrAccessDenied
	}

	p, err := s.repo.getByID(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return nil, ErrNotFound
		}
		return nil, err
	}
	return p, nil
}

func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error) {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return nil, err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return nil, ErrNotFound
		}
		return nil, err
	}
	if !hasAccess {
		return nil, ErrAccessDenied
	}

	// Get the particle to validate data against its type
	p, err := s.repo.getByID(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return nil, ErrNotFound
		}
		return nil, err
	}

	// Validate data matches type requirements
	if err := validateParticleData(p.Type, data); err != nil {
		return nil, err
	}

	err = s.repo.update(ctx, id, data, time.Now())
	if err != nil {
		if errors.Is(err, errNotFound) {
			return nil, ErrNotFound
		}
		return nil, err
	}

	return s.repo.getByID(ctx, id)
}

func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}
	if !hasAccess {
		return ErrAccessDenied
	}

	// Get the particle to check if it's an open stream
	p, err := s.repo.getByID(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	// If it's an open stream, decrement the count
	if p.Type == TypeStream && getStreamStatus(p.Data) == string(StreamStatusOpen) {
		if err := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); err != nil {
			return err
		}
	}

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

func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error) {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return nil, err
	}

	// Network membership is verified by handler - we only check particle visibility
	// If parentID specified, check access to parent (visibility-based)
	if parentID != nil {
		hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
		if err != nil {
			if errors.Is(err, errNotFound) {
				return nil, ErrNotFound
			}
			return nil, err
		}
		if !hasAccess {
			return nil, ErrAccessDenied
		}
	}

	// Fetch one extra to determine if there are more
	// Access filtering is done in the query itself (network_all OR user is member)
	if limit == 0 {
		limit = defaultPageSize
	}
	extraLimit := limit + 1
	particles, err := s.repo.list(ctx, networkID, parentID, requesterEmail, filter, extraLimit, cursor)
	if err != nil {
		return nil, err
	}

	hasMore := len(particles) > limit
	result := &ParticleList{
		HasMore: hasMore,
	}

	if hasMore {
		particles = particles[:limit]
	}
	result.Particles = particles

	// Bidirectional cursors
	if len(particles) > 0 {
		firstParticle := particles[0]
		lastParticle := particles[len(particles)-1]

		result.PrevCursor = &Cursor{
			Position:  firstParticle.UpdatedAt.Format(time.RFC3339Nano),
			Direction: "before",
		}

		if result.HasMore {
			result.NextCursor = &Cursor{
				Position:  lastParticle.UpdatedAt.Format(time.RFC3339Nano),
				Direction: "after",
			}
		}
	}

	return result, nil
}

func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}
	if !hasAccess {
		return ErrAccessDenied
	}

	// Get the particle
	p, err := s.repo.getByID(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	if p.Type != TypeStream {
		return ErrNotAStream
	}

	if getStreamStatus(p.Data) == string(StreamStatusOpen) {
		return ErrStreamAlreadyOpen
	}

	// Check and increment capacity
	err = s.networkSvc.IncrementOpenStreamCount(ctx, p.NetworkID)
	if err != nil {
		if errors.Is(err, network.ErrCapacityExceeded) {
			return ErrCapacityExceeded
		}
		return err
	}

	// Update stream status in data
	newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
	if err != nil {
		if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
			slog.Warn("failed to rollback stream count after status update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
		}
		return err
	}

	err = s.repo.update(ctx, id, newData, time.Now())
	if err != nil {
		// Rollback the capacity increment
		if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
			slog.Warn("failed to rollback stream count after particle update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
		}
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	return nil
}

func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}
	if !hasAccess {
		return ErrAccessDenied
	}

	// Get the particle
	p, err := s.repo.getByID(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	if p.Type != TypeStream {
		return ErrNotAStream
	}

	if getStreamStatus(p.Data) == string(StreamStatusClosed) {
		return ErrStreamAlreadyClosed
	}

	// Update stream status in data
	newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
	if err != nil {
		return err
	}

	err = s.repo.update(ctx, id, newData, time.Now())
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	// Decrement capacity
	return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
}

func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}
	if !hasAccess {
		return ErrAccessDenied
	}

	// Get the particle to check constraints
	p, err := s.repo.getByID(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	// Root particles cannot be inherited
	if mode == VisibilityInherited && p.ParentID == nil {
		return ErrInheritedAtRoot
	}

	// If expanding to network_all, check that parent's effective visibility allows it
	if mode == VisibilityNetworkAll && p.ParentID != nil {
		parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
		if err != nil {
			return err
		}
		if parentVis == VisibilityCustom {
			return ErrAccessExpansion
		}
	}

	err = s.repo.setVisibility(ctx, id, mode)
	if errors.Is(err, errNotFound) {
		return ErrNotFound
	}
	return err
}

// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
	ancestors, err := s.repo.getAncestorChain(ctx, particleID)
	if err != nil {
		return "", err
	}
	if len(ancestors) == 0 {
		return "", errNotFound
	}

	byID := make(map[string]*Particle, len(ancestors))
	for _, p := range ancestors {
		byID[p.ID] = p
	}

	current := ancestors[0]
	for {
		if current.Visibility != VisibilityInherited {
			return current.Visibility, nil
		}
		if current.ParentID == nil {
			return VisibilityNetworkAll, nil
		}
		parent, ok := byID[*current.ParentID]
		if !ok {
			return VisibilityNetworkAll, nil
		}
		current = parent
	}
}

func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}
	if !hasAccess {
		return ErrAccessDenied
	}

	// Get the particle to check type and parent access
	p, err := s.repo.getByID(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	// Only streams can have members
	if p.Type != TypeStream {
		return ErrNotAContainer
	}

	// Validate and normalize emails, check network membership
	normalizedEmails := make([]string, 0, len(emails))
	for _, email := range emails {
		normalized, err := utils.NormalizeEmail(email)
		if err != nil {
			continue
		}

		// Root stream - check network membership
		isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized)
		if err != nil || !isMember {
			continue
		}

		normalizedEmails = append(normalizedEmails, normalized)
	}

	if len(normalizedEmails) == 0 {
		return nil
	}

	return s.repo.addMembers(ctx, id, normalizedEmails)
}

func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}
	if !hasAccess {
		return ErrAccessDenied
	}

	// Get the particle to check type
	p, err := s.repo.getByID(ctx, id)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}

	// Only streams can have members
	if p.Type != TypeStream {
		return ErrNotAContainer
	}

	normalizedEmails := make([]string, 0, len(emails))
	for _, email := range emails {
		normalized, err := utils.NormalizeEmail(email)
		if err != nil {
			continue
		}
		normalizedEmails = append(normalizedEmails, normalized)
	}

	if len(normalizedEmails) == 0 {
		return nil
	}

	return s.repo.removeMembers(ctx, id, normalizedEmails)
}

func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}
	if !hasAccess {
		return ErrAccessDenied
	}

	return s.repo.markSeen(ctx, id, requesterEmail)
}

func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access for each particle and mark seen
	for _, id := range ids {
		hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
		if err != nil {
			if errors.Is(err, errNotFound) {
				continue // Skip non-existent particles
			}
			return err
		}
		if !hasAccess {
			continue // Skip inaccessible particles
		}

		if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
			return err
		}
	}

	return nil
}

func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return err
	}

	// Check access
	hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
	if err != nil {
		if errors.Is(err, errNotFound) {
			return ErrNotFound
		}
		return err
	}
	if !hasAccess {
		return ErrAccessDenied
	}

	// Ack also marks as seen
	if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
		return err
	}

	return s.repo.ack(ctx, id, requesterEmail)
}

func (s *serviceImpl) GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error) {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return nil, err
	}

	return s.repo.getUnseenCounts(ctx, streamIDs, requesterEmail)
}

func (s *serviceImpl) GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error) {
	requesterEmail, err := utils.NormalizeEmail(requesterEmail)
	if err != nil {
		return nil, err
	}

	return s.repo.getSeenMap(ctx, particleIDs, requesterEmail)
}

func (s *serviceImpl) GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) {
	return s.repo.getAcksMap(ctx, particleIDs)
}

func (s *serviceImpl) GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) {
	return s.repo.getMembersMap(ctx, particleIDs)
}

func isValidParticleType(t ParticleType) bool {
	switch t {
	case TypeStream, TypeFolder, TypeMedia, TypeFile, TypeText, TypeQuest, TypePaper:
		return true
	default:
		return false
	}
}

// getStreamStatus extracts the status from a stream particle's data
func getStreamStatus(data json.RawMessage) string {
	var d StreamData
	if err := json.Unmarshal(data, &d); err != nil {
		return ""
	}
	return d.Status
}

// setStreamStatus updates the status in a stream particle's data
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
	var d StreamData
	if err := json.Unmarshal(data, &d); err != nil {
		d = StreamData{}
	}
	d.Status = status
	return json.Marshal(d)
}

// validateParticleData validates that the data field contains valid JSON
// and has required fields for the given particle type.
func validateParticleData(pType ParticleType, data json.RawMessage) error {
	// Empty or null data is allowed - will default to {}
	if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
		return nil
	}

	switch pType {
	case TypeStream:
		var d StreamData
		if err := json.Unmarshal(data, &d); err != nil {
			return errors.Join(ErrInvalidData, err)
		}
		if d.Name == "" {
			return errors.Join(ErrInvalidData, errors.New("stream requires name"))
		}
		if d.Status != string(StreamStatusOpen) && d.Status != string(StreamStatusClosed) {
			return errors.Join(ErrInvalidData, errors.New("stream requires valid status"))
		}

	case TypeFolder:
		var d FolderData
		if err := json.Unmarshal(data, &d); err != nil {
			return errors.Join(ErrInvalidData, err)
		}
		if d.Name == "" {
			return errors.Join(ErrInvalidData, errors.New("folder requires name"))
		}

	case TypeMedia:
		var d MediaData
		if err := json.Unmarshal(data, &d); err != nil {
			return errors.Join(ErrInvalidData, err)
		}
		if d.ObjectID == "" {
			return errors.Join(ErrInvalidData, errors.New("media requires object_id"))
		}
		if d.MimeType == "" {
			return errors.Join(ErrInvalidData, errors.New("media requires mime_type"))
		}
		if d.DurationMs <= 0 {
			return errors.Join(ErrInvalidData, errors.New("media requires positive duration_ms"))
		}

	case TypeFile:
		var d FileData
		if err := json.Unmarshal(data, &d); err != nil {
			return errors.Join(ErrInvalidData, err)
		}
		if d.ObjectID == "" {
			return errors.Join(ErrInvalidData, errors.New("file requires object_id"))
		}
		if d.Filename == "" {
			return errors.Join(ErrInvalidData, errors.New("file requires filename"))
		}
		if d.MimeType == "" {
			return errors.Join(ErrInvalidData, errors.New("file requires mime_type"))
		}
		if d.Size <= 0 {
			return errors.Join(ErrInvalidData, errors.New("file requires non-negative size"))
		}

	case TypeText:
		var d TextData
		if err := json.Unmarshal(data, &d); err != nil {
			return errors.Join(ErrInvalidData, err)
		}
		if d.Content == "" {
			return errors.Join(ErrInvalidData, errors.New("text requires content"))
		}

	case TypeQuest:
		var d QuestData
		if err := json.Unmarshal(data, &d); err != nil {
			return errors.Join(ErrInvalidData, err)
		}
		if d.Title == "" {
			return errors.Join(ErrInvalidData, errors.New("quest requires title"))
		}
		if d.Description == "" {
			return errors.Join(ErrInvalidData, errors.New("quest requires description"))
		}

	case TypePaper:
		var d PaperData
		if err := json.Unmarshal(data, &d); err != nil {
			return errors.Join(ErrInvalidData, err)
		}
		if d.Title == "" {
			return errors.Join(ErrInvalidData, errors.New("paper requires title"))
		}
		if d.Content == "" {
			return errors.Join(ErrInvalidData, errors.New("paper requires content"))
		}
	}

	return nil
}
