package models

import (
	"context"
	"errors"
	"log"
	"strings"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
// SeedPlans.
//
// The original three rows were keyed on tier alone: (cloud,free),
// (cloud,professional) and a self_hosted TIER row. Spec 7 re-keys on
// (deployment, tier), makes self_hosted a deployment rather than a tier, and
// renames limits/features to base_limits/base_features.
//
// Ordering is the whole point. SeedPlans inserts a fresh (self_hosted,
// professional) row; if the legacy self_hosted row is only renamed afterwards it
// collides with that seed on deployment_tier_unique. Running here, before the
// seed, the rename lands first and the seed then no-ops on it.
//
// It is idempotent and self-healing: on a database where a previous boot already
// seeded (self_hosted, professional) before crashing, the legacy row can no
// longer be renamed onto it, so the legacy row is dropped instead — its
// instances are re-tiered and re-entitled from the surviving professional row.
func MigrateLegacyPlans(ctx context.Context) error {
	// Legacy cloud rows may predate the deployment field entirely.
	if _, err := db.Admin("plans").UpdateMany(ctx,
		bson.M{"deployment": bson.M{"$exists": false},
			"tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}},
		bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil {
		return err
	}

	// The legacy self_hosted TIER row becomes self-hosted Professional. If that
	// target already exists (a prior partial boot seeded it), drop the legacy row
	// rather than colliding — the seeded row carries the same professional base.
	var legacy Plan
	err := db.Admin("plans").FindOne(ctx, bson.M{"tier": license.TierSelfHosted}).Decode(&legacy)
	switch {
	case err == nil:
		targetErr := db.Admin("plans").FindOne(ctx,
			bson.M{"deployment": license.DeploymentSelfHosted, "tier": license.TierProfessional}).Err()
		if targetErr == nil {
			if _, err := db.Admin("plans").DeleteOne(ctx, bson.M{"_id": legacy.ID}); err != nil {
				return err
			}
			log.Printf("backfill: dropped legacy self_hosted plan row; (self_hosted, professional) already present")
		} else if errors.Is(targetErr, mongo.ErrNoDocuments) {
			if _, err := db.Admin("plans").UpdateOne(ctx,
				bson.M{"_id": legacy.ID},
				bson.M{"$set": bson.M{
					"deployment": license.DeploymentSelfHosted,
					"tier":       license.TierProfessional,
					"name":       "Professional",
				}}); err != nil {
				return err
			}
			log.Printf("backfill: re-keyed legacy self_hosted plan to (self_hosted, professional)")
		} else {
			return targetErr
		}
	case errors.Is(err, mongo.ErrNoDocuments):
		// No legacy row; a fresh database or an already-migrated one.
	default:
		return err
	}

	// limits/features become base_limits/base_features on any row still carrying
	// the old names.
	if _, err := db.Admin("plans").UpdateMany(ctx,
		bson.M{"limits": bson.M{"$exists": true}},
		bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil {
		return err
	}

	// Support level is new, so nothing has one. Fill from the seed table rather
	// than guessing: a plan row a human edited keeps every other field.
	for _, deployment := range license.Deployments() {
		for _, tier := range license.Tiers() {
			p, ok := license.PlanFor(deployment, tier)
			if !ok {
				continue
			}
			if _, err := db.Admin("plans").UpdateOne(ctx,
				bson.M{"deployment": deployment, "tier": tier,
					"support_level": bson.M{"$in": bson.A{nil, ""}}},
				bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil {
				return err
			}
		}
	}
	return nil
}

// Backfill brings pre-phase-3 data up to the membership model.
//
// It runs on every boot and is idempotent by construction: both passes filter
// on the absence of what they write. There is no migrations collection in
// admin, and adding one for two `$exists: false` queries would be more
// machinery than the job deserves.
//
// It lives in models rather than db for the same reason SeedPlans does: db is
// the connection layer and importing models there is an import cycle.
func Backfill(ctx context.Context) error {
	// Pass 1: every existing customer_user created their own account, so they
	// are all owners. A row with no account_role would otherwise be able to do
	// nothing at all once the guards land — including managing the account it
	// created.
	res, err := db.Admin("customer_users").UpdateMany(ctx,
		bson.M{"account_role": bson.M{"$exists": false}},
		bson.M{"$set": bson.M{"account_role": AccountRoleOwner}})
	if err != nil {
		return err
	}
	if res.ModifiedCount > 0 {
		log.Printf("backfill: set account_role=owner on %d customer_users", res.ModifiedCount)
	}

	// Pass 2: phase 2 created cloud instances and their owners without an
	// instance_members row, because the collection did not exist. Reconstruct
	// one per instance from the control-plane owner it actually created.
	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
		"deployment": license.DeploymentCloud,
		"status":     bson.M{"$ne": StatusDeleted},
	})
	if err != nil {
		return err
	}
	var instances []Instance
	if err := cur.All(ctx, &instances); err != nil {
		return err
	}

	created := 0
	for _, inst := range instances {
		n, err := db.Admin("instance_members").CountDocuments(ctx,
			bson.M{"instance_id": inst.InstanceID})
		if err != nil {
			return err
		}
		if n > 0 {
			continue
		}

		// Only an hq-sourced owner can be reconstructed: a control-plane owner
		// with no hq_user_id was created inside the instance and belongs to
		// nobody on this side. Leaving it unrecorded is correct.
		var owner sharedmodels.User
		err = db.Control("users").FindOne(ctx, bson.M{
			"instance_id": inst.InstanceID,
			"role":        sharedmodels.RoleOwner,
			"hq_user_id":  bson.M{"$nin": bson.A{nil, ""}},
		}).Decode(&owner)
		if err != nil {
			if err != mongo.ErrNoDocuments {
				return err
			}
			log.Printf("backfill: instance %s has no hq-sourced owner; left unrecorded", inst.InstanceID)
			continue
		}

		if _, err := db.Admin("instance_members").InsertOne(ctx, InstanceMember{
			MemberID:       uuid.NewString(),
			AccountID:      inst.AccountID,
			InstanceID:     inst.InstanceID,
			CustomerUserID: owner.HQUserID,
			ControlUserID:  owner.UserID,
			Role:           sharedmodels.RoleOwner,
			Email:          owner.Email,
			CreatedAt:      time.Now().UTC(),
		}); err != nil {
			return err
		}
		created++
	}
	if created > 0 {
		log.Printf("backfill: recorded %d pre-existing instance owners", created)
	}

	// Pass 3 (the plan re-key) now runs in MigrateLegacyPlans, called from main
	// BEFORE SeedPlans. It has to: SeedPlans inserts a fresh
	// (self_hosted, professional) row, and if the legacy self_hosted TIER row is
	// only renamed afterwards it collides with that seed on deployment_tier_unique.

	// Pass 4: instances carrying the self_hosted TIER move to Professional.
	// Their deployment already says self_hosted, so only the tier is wrong.
	res, err = db.Admin("admin_instances").UpdateMany(ctx,
		bson.M{"tier": license.TierSelfHosted},
		bson.M{"$set": bson.M{"tier": license.TierProfessional}})
	if err != nil {
		return err
	}
	if res.ModifiedCount > 0 {
		log.Printf("backfill: re-tiered %d self-hosted instances to professional", res.ModifiedCount)
	}

	// Pass 5: give every instance an entitlement, reconstructed from its current
	// licence. Filtering on the absence of a row is what makes this idempotent,
	// and it means an entitlement a customer has since edited is never
	// overwritten by a stale licence.
	if err := backfillEntitlements(ctx); err != nil {
		return err
	}

	// Pass 6: instances whose identity was rewritten before previous_instance_ids
	// existed carry no trail, and Paddle's custom_data still names the id they
	// were rewritten FROM — so their next webhook resolves to nothing. Both
	// rewrites wrote an audit entry naming the old id, which is the only surviving
	// record of it, so reconstruct the trail from those.
	if err := backfillInstanceIDHistory(ctx); err != nil {
		return err
	}
	return nil
}

// backfillInstanceIDHistory rebuilds previous_instance_ids from the audit entries
// the two identity rewrites leave behind: a placeholder claim
// ("instance.placeholder_linked", detail "from placeholder <id>") and a relink
// ("instance.relinked", detail "was <id>").
//
// $addToSet is what makes it idempotent, and it also means a chain of relinks
// accumulates rather than the last one winning. Entries are walked NEWEST first,
// matching on the current id or an already-recovered one: an instance relinked
// A→B→C answers to neither A nor B by the time this runs, so the C entry has to
// record B before the B entry has anything to attach A to.
func backfillInstanceIDHistory(ctx context.Context) error {
	prefixes := map[string]string{
		"instance.placeholder_linked": "from placeholder ",
		"instance.relinked":           "was ",
	}
	actions := make(bson.A, 0, len(prefixes))
	for action := range prefixes {
		actions = append(actions, action)
	}

	cur, err := db.Admin("admin_audit").Find(ctx,
		bson.M{"action": bson.M{"$in": actions}},
		options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
	if err != nil {
		return err
	}
	var entries []AuditEntry
	if err := cur.All(ctx, &entries); err != nil {
		return err
	}

	recorded := 0
	for _, e := range entries {
		prefix := prefixes[e.Action]
		if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) {
			continue
		}
		oldID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
		if oldID == "" || oldID == e.Target {
			continue
		}
		res, err := db.Admin("admin_instances").UpdateOne(ctx,
			bson.M{"$or": bson.A{
				bson.M{"instance_id": e.Target},
				bson.M{"previous_instance_ids": e.Target},
			}},
			bson.M{"$addToSet": bson.M{"previous_instance_ids": oldID}})
		if err != nil {
			return err
		}
		recorded += int(res.ModifiedCount)
	}
	if recorded > 0 {
		log.Printf("backfill: recovered %d instance id rewrites from the audit log", recorded)
	}
	return nil
}

// backfillEntitlements reconstructs an entitlement per instance from its licence.
//
// An Unlimited max_servers maps back to the plan's BASE allowance rather than to
// a huge number: an unlimited licence bought no server units, so the honest
// reconstruction of "how many did they pay for" is none. This makes a
// pre-metering Professional instance read as 3 servers, which is a REDUCTION in
// what it is allowed. That is deliberate and it is why this is a plan step and
// not a silent fix — see the task's confirmation step.
func backfillEntitlements(ctx context.Context) error {
	cur, err := db.Admin("admin_instances").Find(ctx,
		bson.M{"status": bson.M{"$ne": StatusDeleted}})
	if err != nil {
		return err
	}
	var instances []Instance
	if err := cur.All(ctx, &instances); err != nil {
		return err
	}

	created := 0
	for _, inst := range instances {
		n, err := db.Admin("entitlements").CountDocuments(ctx,
			bson.M{"instance_id": inst.InstanceID})
		if err != nil {
			return err
		}
		if n > 0 {
			continue
		}

		deployment, tier := license.NormaliseTier(inst.Deployment, inst.Tier)
		if tier == "" {
			// An instance awaiting its first licence has no tier. It gets an
			// entitlement when one is issued, not before.
			continue
		}
		plan, err := GetPlan(ctx, deployment, tier)
		if err != nil {
			log.Printf("backfill: instance %s names unknown plan %s/%s; skipped",
				inst.InstanceID, deployment, tier)
			continue
		}

		cfg := Config{Servers: plan.BaseLimits.MaxServers, Features: Features{}}
		if inst.CurrentLicense != "" {
			var lic License
			if err := db.Admin("licenses").FindOne(ctx,
				bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil {
				if lic.Limits.MaxServers != license.Unlimited && lic.Limits.MaxServers > 0 {
					cfg.Servers = lic.Limits.MaxServers
				}
				cfg.Features = lic.Features.OrEmpty()
			}
		}

		limits := plan.BaseLimits
		limits.MaxServers = cfg.Servers
		if err := UpsertEntitlement(ctx, Entitlement{
			InstanceID:     inst.InstanceID,
			AccountID:      inst.AccountID,
			Deployment:     deployment,
			Tier:           tier,
			Term:           defaultTerm(deployment),
			Desired:        cfg,
			Granted:        cfg,
			ResolvedLimits: limits,
		}); err != nil {
			return err
		}
		created++
	}
	if created > 0 {
		log.Printf("backfill: created %d entitlements from current licences", created)
	}
	return nil
}

// defaultTerm is the term to assume for a reconstructed entitlement. Self-hosted
// sells annual only, so there is nothing to guess there.
func defaultTerm(deployment string) string {
	if deployment == license.DeploymentSelfHosted {
		return "annual"
	}
	return "monthly"
}
