package services

import (
	"context"
	"fmt"
	"log"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

// ScopedCollections lists every collection carrying the tenant key.
//
// Migration 0004 renames org_id to instance_id in each. A collection missing
// from this list keeps the old field name and becomes invisible to every scoped
// query — so this list is load-bearing, not documentation.
//
// AssertNoScopedCollectionMissed checks at boot that nothing outside this list
// holds an org_id.
//
// The migrations collection is deliberately absent: it is not tenant-scoped.
// The two renamed collections appear under their post-rename names, because the
// migration renames the collections before it renames the field.
var ScopedCollections = []string{
	"instances",
	"servers",
	"keys",
	"assignments",
	"users",
	"instance_oidc",
	"settings",
	"secrets",
	"workflows",
	"workflow_steps",
	"workflow_runs",
	"monitors",
	"incidents",
	"monitor_rollups",
	"notification_channels",
	"console_sessions",
	"audit_logs",
	"auth_providers",
	"server_packages",
	"vuln_findings",
	"vuln_alert_rules",
	"server_workloads",
}

// collectionRenames maps the two collections whose names change. Ordered so the
// migration is deterministic.
var collectionRenames = []struct{ from, to string }{
	{"orgs", "instances"},
	{"org_oidc", "instance_oidc"},
}

// MigrateOrgToInstance renames the tenant key from org_id to instance_id.
//
// It only ever renames documents. It never deletes, drops or unsets one, so a
// bad deploy is recovered by running the inverse rename (cmd/rename-rollback)
// rather than by restoring a backup.
//
// The steps are not atomic across collections — multi-document transactions
// would require a replica set, which self-hosted installs do not guarantee.
// Instead every step is safely repeatable: a collection rename is skipped when
// the source is already gone, and $rename matches nothing on a document that
// has already been renamed. A run that fails partway is fixed by running it
// again.
//
// It must run BEFORE EnsureAuthIndexes. Creating the instances.slug index first
// would create an empty instances collection, and step 1 below refuses to
// rename orgs onto an existing target.
func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error {
	names, err := db.ListCollectionNames(ctx, bson.M{})
	if err != nil {
		return fmt.Errorf("list collections: %w", err)
	}
	exists := map[string]bool{}
	for _, n := range names {
		exists[n] = true
	}

	// Step 1: rename the collections.
	for _, r := range collectionRenames {
		switch {
		case !exists[r.from]:
			// Nothing to rename: either already done or never existed.
			continue
		case exists[r.to]:
			return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to)
		}
		cmd := bson.D{
			{Key: "renameCollection", Value: db.Name() + "." + r.from},
			{Key: "to", Value: db.Name() + "." + r.to},
		}
		if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
			return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err)
		}
		log.Printf("0004: renamed collection %s to %s", r.from, r.to)
	}

	// Step 2: drop indexes keyed on the old field name, BEFORE renaming it.
	//
	// Order matters and is not obvious. A unique index on org_id treats a
	// missing org_id as null, so as soon as $rename strips the field from the
	// second document the index reports a duplicate null and the whole update
	// fails. Dropping first avoids that entirely.
	//
	// Dropping an index touches no documents. The boot-time index builders
	// recreate the current ones against the new field name.
	for _, c := range ScopedCollections {
		if err := DropIndexesKeyedOn(ctx, db, c, "org_id"); err != nil {
			return err
		}
	}

	// Step 3: rename the field.
	for _, c := range ScopedCollections {
		res, err := db.Collection(c).UpdateMany(ctx,
			bson.M{"org_id": bson.M{"$exists": true}},
			bson.M{"$rename": bson.M{"org_id": "instance_id"}},
		)
		if err != nil {
			return fmt.Errorf("rename org_id in %s: %w", c, err)
		}
		if res.ModifiedCount > 0 {
			log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount)
		}
	}

	// Step 4: verify before anyone records a marker. Any mismatch aborts, and
	// the migration is re-run rather than marked done.
	for _, c := range ScopedCollections {
		total, err := db.Collection(c).CountDocuments(ctx, bson.M{})
		if err != nil {
			return fmt.Errorf("count %s: %w", c, err)
		}
		if total == 0 {
			continue
		}

		stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
		if err != nil {
			return fmt.Errorf("count stale in %s: %w", c, err)
		}
		if stale != 0 {
			return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale)
		}

		scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}})
		if err != nil {
			return fmt.Errorf("count scoped in %s: %w", c, err)
		}
		if scoped != total {
			return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped)
		}
	}

	log.Printf("0004: verified %d collection(s)", len(ScopedCollections))
	return nil
}

// IndexKeyedOn reports whether an index specification's key document mentions
// field.
//
// The key is checked as both bson.D and bson.M because the driver's decoding of
// a nested document depends on the target type, and getting this wrong is
// silent: the index simply is not found, and the field rename then fails on a
// duplicate null.
func IndexKeyedOn(key any, field string) bool {
	switch k := key.(type) {
	case bson.M:
		_, ok := k[field]
		return ok
	case bson.D:
		for _, e := range k {
			if e.Key == field {
				return true
			}
		}
	}
	return false
}

// DropIndexesKeyedOn removes every index on coll whose key mentions field,
// leaving _id_ alone.
//
// Both the migration and its rollback must do this BEFORE renaming the field. A
// unique index treats a missing field as null, so as soon as $rename strips the
// field from the second document the index reports a duplicate null and the
// whole update fails. Dropping an index touches no documents; the boot-time
// index builders recreate what is needed.
func DropIndexesKeyedOn(ctx context.Context, db *mongo.Database, coll, field string) error {
	cur, err := db.Collection(coll).Indexes().List(ctx)
	if err != nil {
		return fmt.Errorf("list indexes on %s: %w", coll, err)
	}
	var specs []bson.M
	if err := cur.All(ctx, &specs); err != nil {
		return fmt.Errorf("decode indexes on %s: %w", coll, err)
	}
	for _, s := range specs {
		name, _ := s["name"].(string)
		if name == "_id_" {
			continue
		}
		if !IndexKeyedOn(s["key"], field) {
			continue
		}
		if err := db.Collection(coll).Indexes().DropOne(ctx, name); err != nil {
			return fmt.Errorf("drop index %s on %s: %w", name, coll, err)
		}
		log.Printf("dropped stale index %s on %s", name, coll)
	}
	return nil
}

// AssertNoScopedCollectionMissed reports any collection holding an org_id that
// ScopedCollections does not know about. A hit means a collection was added
// without being added to the list, and its tenant key was never renamed.
func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error {
	known := map[string]bool{}
	for _, c := range ScopedCollections {
		known[c] = true
	}

	names, err := db.ListCollectionNames(ctx, bson.M{})
	if err != nil {
		return fmt.Errorf("list collections: %w", err)
	}

	for _, n := range names {
		if known[n] {
			continue
		}
		count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
		if err != nil {
			return fmt.Errorf("count %s: %w", n, err)
		}
		if count > 0 {
			return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count)
		}
	}
	return nil
}
