package repository

import (
	"context"
	"strings"
	"time"

	"apps/backend/internal/module/radar/domain"

	"github.com/zeromicro/go-zero/core/stores/mon"
	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
)

/*
UpsertByExternalID：

- 未命中：完整寫入（ValidateForWrite，含 OP-06 reasons 契約）
- 已命中：只 $addToSet matched_terms，不覆寫判定結果

unique 索引 (owner_uid, external_id) 由 migration 000014 建立。
*/
func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity) (*domain.Opportunity, error) {
	if o == nil {
		return nil, domain.ErrValidation
	}
	externalID := strings.TrimSpace(o.ExternalID)
	if o.OwnerUID <= 0 || externalID == "" {
		return nil, domain.ErrValidation
	}

	// Fast path: existing row — merge terms only.
	var existing domain.Opportunity
	err := s.opportunities.FindOne(ctx, &existing, bson.M{
		"owner_uid":   o.OwnerUID,
		"external_id": externalID,
	})
	if err == nil {
		now := domain.NowNano()
		set := bson.M{"updated_at": now, "last_matched_at": now}
		update := bson.M{"$set": set}
		if terms := domain.NormalizeMatchedTerms(o.MatchedTerms); len(terms) > 0 {
			update["$addToSet"] = bson.M{"matched_terms": bson.M{"$each": terms}}
		}
		if _, uerr := s.opportunities.UpdateOne(ctx, bson.M{"_id": existing.ID}, update); uerr != nil {
			return nil, uerr
		}
		if rerr := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": existing.ID}); rerr != nil {
			return nil, rerr
		}
		for _, match := range o.ProductMatches {
			if _, merr := s.MergeProductMatch(ctx, o.OwnerUID, existing.ID, match); merr != nil {
				return nil, merr
			}
		}
		if len(o.ProductMatches) > 0 {
			return s.GetOpportunity(ctx, existing.ID)
		}
		return &existing, nil
	}
	if err != mon.ErrNotFound {
		return nil, err
	}

	if err := o.ValidateForWrite(); err != nil {
		return nil, err
	}
	domain.ApplyPrimaryProduct(o)
	if o.ID == "" {
		o.ID = domain.NewID()
	}
	now := domain.NowNano()
	if o.CreatedAt == 0 {
		o.CreatedAt = now
	}
	o.UpdatedAt = now
	if o.IntentBand == "" && o.Status != domain.OppJudging {
		o.ApplyBandFromScore()
	}
	if o.ReviewState == "" {
		o.ReviewState = reviewStateFor(o)
	}
	if o.LastMatchedAt == 0 {
		o.LastMatchedAt = now
	}
	o.ExternalID = externalID

	_, err = s.opportunities.InsertOne(ctx, o)
	if err != nil {
		// Race: another worker inserted the same external_id — fall back to merge.
		if mongo.IsDuplicateKeyError(err) {
			return s.UpsertByExternalID(ctx, o)
		}
		return nil, err
	}
	return o, nil
}

func (s *MonStore) MergeProductMatch(ctx context.Context, ownerUID int64, opportunityID string, match *domain.ProductMatch) (*domain.Opportunity, error) {
	if ownerUID <= 0 || match == nil {
		return nil, domain.ErrValidation
	}
	if err := match.ValidateForWrite(); err != nil {
		return nil, err
	}
	var existing domain.Opportunity
	if err := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": opportunityID, "owner_uid": ownerUID}); err == mon.ErrNotFound {
		return nil, domain.ErrNotFound
	} else if err != nil {
		return nil, err
	}
	// A guarded insert means concurrent workers cannot both append the same product.
	res, err := s.opportunities.UpdateOne(ctx,
		bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches": bson.M{"$not": bson.M{"$elemMatch": bson.M{"product_id": match.ProductID}}}},
		bson.M{"$push": bson.M{"product_matches": match}, "$set": bson.M{"updated_at": domain.NowNano()}})
	if err != nil {
		return nil, err
	}
	if res.MatchedCount == 0 {
		_, err = s.opportunities.UpdateOne(ctx,
			bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches": bson.M{"$elemMatch": bson.M{"product_id": match.ProductID}}},
			bson.M{"$addToSet": bson.M{"product_matches.$.watch_ids": bson.M{"$each": match.WatchIDs}, "product_matches.$.matched_terms": bson.M{"$each": match.MatchedTerms}}, "$set": bson.M{"updated_at": domain.NowNano()}})
		if err != nil {
			return nil, err
		}
	}
	if err := s.autoArbitratePrimary(ctx, ownerUID, opportunityID); err != nil {
		return nil, err
	}
	return s.GetOpportunity(ctx, opportunityID)
}

func (s *MonStore) autoArbitratePrimary(ctx context.Context, ownerUID int64, opportunityID string) error {
	o, err := s.GetOpportunity(ctx, opportunityID)
	if err != nil || o.PrimaryProductOverridden {
		return err
	}
	before := o.PrimaryProductID
	domain.ApplyPrimaryProduct(o)
	if o.PrimaryProductID == "" || o.PrimaryProductID == before && o.IntentScore == 0 {
		return nil
	}
	set := bson.M{
		"primary_brand_id": o.PrimaryBrandID, "primary_product_id": o.PrimaryProductID,
		"primary_brand_name": o.PrimaryBrandName, "primary_product_label": o.PrimaryProductLabel,
		"primary_product_fit_score": o.PrimaryProductFitScore, "primary_product_fit_band": o.PrimaryProductFitBand,
		"intent_score": o.IntentScore, "intent_band": o.IntentBand, "reasons": o.Reasons,
		"updated_at": domain.NowNano(),
	}
	_, err = s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID, "primary_product_overridden": bson.M{"$ne": true}}, bson.M{"$set": set})
	return err
}

func (s *MonStore) SetPrimaryProduct(ctx context.Context, ownerUID int64, opportunityID, productID string, override *domain.ProductPrimaryOverride) (*domain.Opportunity, error) {
	o, err := s.GetOpportunity(ctx, opportunityID)
	if err != nil {
		return nil, err
	}
	if o.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	var match *domain.ProductMatch
	for _, candidate := range o.ProductMatches {
		if candidate.ProductID == productID {
			match = candidate
			break
		}
	}
	if match == nil || ((!match.Eligible || match.Excluded) && (override == nil || strings.TrimSpace(override.Reason) == "")) {
		return nil, domain.ErrValidation
	}
	set := bson.M{"primary_brand_id": match.BrandID, "primary_product_id": match.ProductID, "primary_brand_name": match.BrandNameSnapshot, "primary_product_label": match.ProductLabelSnapshot, "primary_product_fit_score": match.ProductFitScore, "primary_product_fit_band": match.ProductFitBand, "primary_product_overridden": override != nil, "updated_at": domain.NowNano()}
	if override != nil {
		if override.At == 0 {
			override.At = domain.NowNano()
		}
		set["primary_product_override"] = override
	}
	o.PrimaryProductOverridden = override != nil
	o.PrimaryProductID = productID
	o.PrimaryProductOverride = override
	domain.ApplyPrimaryProduct(o)
	set["intent_score"], set["intent_band"], set["reasons"] = o.IntentScore, o.IntentBand, o.Reasons
	if _, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches.product_id": productID}, bson.M{"$set": set}); err != nil {
		return nil, err
	}
	return s.GetOpportunity(ctx, opportunityID)
}

func (s *MonStore) GetOpportunity(ctx context.Context, id string) (*domain.Opportunity, error) {
	var o domain.Opportunity
	err := s.opportunities.FindOne(ctx, &o, bson.M{"_id": id})
	if err == mon.ErrNotFound {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, err
	}
	return &o, nil
}

func (s *MonStore) GetByExternalID(ctx context.Context, ownerUID int64, externalID string) (*domain.Opportunity, error) {
	var o domain.Opportunity
	err := s.opportunities.FindOne(ctx, &o, bson.M{
		"owner_uid":   ownerUID,
		"external_id": strings.TrimSpace(externalID),
	})
	if err == mon.ErrNotFound {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, err
	}
	return &o, nil
}

func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f domain.OpportunityListFilter) ([]*domain.Opportunity, int64, error) {
	q := bson.M{"owner_uid": ownerUID}
	if f.Band != "" {
		q["intent_band"] = f.Band
	}
	if len(f.Statuses) > 0 {
		q["status"] = bson.M{"$in": f.Statuses}
	} else if f.Status != "" {
		q["status"] = f.Status
	}
	if f.WatchID != "" {
		q["watch_id"] = f.WatchID
	}
	if f.BrandID != "" {
		q["product_matches.brand_id"] = f.BrandID
	}
	if f.ProductID != "" {
		q["product_matches.product_id"] = f.ProductID
	}
	if f.FitBand != "" {
		q["product_matches.product_fit_band"] = f.FitBand
	}
	if f.PriorityBand != "" {
		q["priority_band"] = f.PriorityBand
	}
	if f.ReviewState != "" {
		appendAnd(q, reviewStateListClause(f.ReviewState))
	}
	switch f.MatchState {
	case "eligible":
		q["product_matches"] = bson.M{"$elemMatch": bson.M{"eligible": true, "excluded": false}}
	case "weak":
		q["product_matches.product_fit_band"] = domain.ProductFitBandWeak
	case "excluded":
		q["product_matches.excluded"] = true
	case "generic":
		appendAnd(q, bson.M{"$or": bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}}})
	case "stale":
		appendAnd(q, bson.M{"posted_at": bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)}})
	}
	if f.CreatedFrom > 0 || f.CreatedTo > 0 {
		rng := bson.M{}
		if f.CreatedFrom > 0 {
			rng["$gte"] = f.CreatedFrom
		}
		if f.CreatedTo > 0 {
			rng["$lt"] = f.CreatedTo
		}
		q["created_at"] = rng
	}
	if f.PostedFrom > 0 || f.PostedTo > 0 {
		if f.TimeScope == "today" || f.TimeScope == "7d" {
			appendAnd(q, inboxTimeScopeClause(f.PostedFrom, f.PostedTo))
		} else {
			rng := bson.M{}
			if f.PostedFrom > 0 {
				rng["$gte"] = f.PostedFrom
			}
			if f.PostedTo > 0 {
				rng["$lt"] = f.PostedTo
			}
			q["posted_at"] = rng
		}
	}

	total, err := s.opportunities.CountDocuments(ctx, q)
	if err != nil {
		return nil, 0, err
	}
	page, ps := f.Page, f.PageSize
	if page < 1 {
		page = 1
	}
	if ps < 1 {
		ps = 20
	}
	var list []*domain.Opportunity
	sortSpec := bson.D{{Key: "intent_score", Value: -1}, {Key: "primary_product_fit_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
	if f.Sort == "posted" || f.Sort == "newest" {
		sortSpec = bson.D{{Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
	} else if f.Sort == "oldest" {
		sortSpec = bson.D{{Key: "posted_at", Value: 1}, {Key: "_id", Value: 1}}
	} else if f.Sort == "product_fit" {
		sortSpec = bson.D{{Key: "primary_product_fit_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
	} else if f.Sort == "demand_intent" {
		sortSpec = bson.D{{Key: "intent_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
	} else if f.Sort == "priority" || f.Sort == "priority_score" {
		sortSpec = bson.D{{Key: "priority_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
	}
	err = s.opportunities.Find(ctx, &list, q, options.Find().
		SetSort(sortSpec).
		SetSkip(int64((page-1)*ps)).
		SetLimit(int64(ps)))
	return list, total, err
}

func (s *MonStore) UpdateOpportunityReviewState(ctx context.Context, ownerUID int64, opportunityID string, patch domain.ReviewStatePatch) (*domain.Opportunity, error) {
	if ownerUID <= 0 || !domain.IsReviewState(patch.State) {
		return nil, domain.ErrValidation
	}
	o, err := s.GetOpportunity(ctx, opportunityID)
	if err != nil {
		return nil, err
	}
	if o.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	current := reviewStateFor(o)
	set := bson.M{"review_state": patch.State, "updated_at": domain.NowNano()}
	if patch.State == domain.ReviewRemoved {
		if !domain.IsRemovalReason(patch.RemovalReason) || patch.RemovalReason == domain.RemovalOther && strings.TrimSpace(patch.RemovalNote) == "" {
			return nil, domain.ErrValidation
		}
		if patch.RemovalReason == domain.RemovalDuplicate {
			if patch.DuplicateOpportunityID == "" || patch.DuplicateOpportunityID == opportunityID {
				return nil, domain.ErrValidation
			}
			var target domain.Opportunity
			if err := s.opportunities.FindOne(ctx, &target, bson.M{"_id": patch.DuplicateOpportunityID, "owner_uid": ownerUID, "author_handle": o.AuthorHandle}); err != nil {
				return nil, domain.ErrValidation
			}
		}
		set["previous_review_state"] = current
		set["removal_reason"] = patch.RemovalReason
		set["removal_note"] = strings.TrimSpace(patch.RemovalNote)
		set["removed_at"] = domain.NowNano()
		set["removed_by"] = ownerUID
	} else {
		set["removal_reason"] = ""
		set["removal_note"] = ""
		set["removed_at"] = int64(0)
		set["removed_by"] = int64(0)
	}
	res, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID}, bson.M{"$set": set})
	if err != nil {
		return nil, err
	}
	if res.MatchedCount == 0 {
		return nil, domain.ErrNotFound
	}
	event := &domain.ReviewEvent{OwnerUID: ownerUID, OpportunityID: opportunityID, From: current, To: patch.State, Reason: patch.RemovalReason, Note: patch.RemovalNote, ActorUID: ownerUID, At: domain.NowNano()}
	if err := event.Normalize(); err != nil {
		return nil, err
	}
	if _, err := s.reviewEvents.InsertOne(ctx, event); err != nil {
		return nil, err
	}
	return s.GetOpportunity(ctx, opportunityID)
}

func (s *MonStore) CountToday(ctx context.Context, ownerUID int64, at int64) (int64, error) {
	start, end := domain.UTCDayBounds(at)
	return s.opportunities.CountDocuments(ctx, bson.M{
		"owner_uid": ownerUID,
		"created_at": bson.M{
			"$gte": start,
			"$lt":  end,
		},
	})
}

func (s *MonStore) UpdateOpportunityStatus(ctx context.Context, id string, status string) error {
	o, err := s.GetOpportunity(ctx, id)
	if err != nil {
		return err
	}
	if err := o.Transition(status); err != nil {
		return err
	}
	res, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": id}, bson.M{
		"$set": bson.M{"status": o.Status, "updated_at": o.UpdatedAt},
	})
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return domain.ErrNotFound
	}
	return nil
}

func (s *MonStore) SetOpportunityContact(ctx context.Context, id, contactID string) error {
	res, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": id}, bson.M{
		"$set": bson.M{"contact_id": contactID, "updated_at": domain.NowNano()},
	})
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return domain.ErrNotFound
	}
	return nil
}

func (s *MonStore) SetOpportunityOverride(ctx context.Context, id string, ov *domain.OpportunityOverride, newBand, newStatus string) error {
	if ov == nil {
		return domain.ErrValidation
	}
	o, err := s.GetOpportunity(ctx, id)
	if err != nil {
		return err
	}
	if newStatus != "" && newStatus != o.Status {
		if err := o.Transition(newStatus); err != nil {
			return err
		}
	}
	set := bson.M{"updated_at": domain.NowNano()}
	if newStatus != "" {
		set["status"] = o.Status
	}
	if newBand != "" {
		if !domain.IsIntentBand(newBand) {
			return domain.ErrValidation
		}
		set["intent_band"] = newBand
	}
	if ov.At == 0 {
		ov.At = domain.NowNano()
	}
	set["override"] = ov

	res, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": set})
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return domain.ErrNotFound
	}
	return nil
}

func appendAnd(q bson.M, clause bson.M) {
	if len(clause) == 0 {
		return
	}
	if existing, ok := q["$and"].(bson.A); ok {
		q["$and"] = append(existing, clause)
		return
	}
	q["$and"] = bson.A{clause}
}

// reviewStateListClause implements spec §3.2: missing review_state is derived
// from OpportunityStatus so a list query never requires a one-shot migration.
func reviewStateListClause(state string) bson.M {
	missing := bson.A{
		bson.M{"review_state": bson.M{"$exists": false}},
		bson.M{"review_state": ""},
	}
	switch state {
	case domain.ReviewPending:
		return bson.M{"$or": bson.A{
			bson.M{"review_state": domain.ReviewPending},
			bson.M{"$and": bson.A{
				bson.M{"$or": missing},
				bson.M{"status": bson.M{"$nin": []string{domain.OppAccepted, domain.OppDismissed}}},
			}},
		}}
	case domain.ReviewCompleted:
		return bson.M{"$or": bson.A{
			bson.M{"review_state": domain.ReviewCompleted},
			bson.M{"$and": bson.A{
				bson.M{"$or": missing},
				bson.M{"status": domain.OppAccepted},
			}},
		}}
	case domain.ReviewRemoved:
		return bson.M{"$or": bson.A{
			bson.M{"review_state": domain.ReviewRemoved},
			bson.M{"$and": bson.A{
				bson.M{"$or": missing},
				bson.M{"status": domain.OppDismissed},
			}},
		}}
	default:
		return bson.M{"review_state": state}
	}
}

func inboxTimeScopeClause(from, to int64) bson.M {
	rng := bson.M{}
	if from > 0 {
		rng["$gte"] = from
	}
	if to > 0 {
		rng["$lt"] = to
	}
	// Same predicate as domain.InInboxTimeScope: posted in range, or
	// last_matched in range, or last_matched missing/0 and created in range.
	// Must not gate created_at on posted_at — a just-found older post has a
	// real posted_at from yesterday and no last_matched_at (no-backfill).
	missingLastMatched := bson.A{
		bson.M{"last_matched_at": bson.M{"$exists": false}},
		bson.M{"last_matched_at": int64(0)},
	}
	return bson.M{"$or": bson.A{
		bson.M{"posted_at": rng},
		bson.M{"last_matched_at": rng},
		bson.M{"$and": bson.A{
			bson.M{"$or": missingLastMatched},
			bson.M{"created_at": rng},
		}},
	}}
}
