// Package cloudprov provisions cloud instances in the control plane.
//
// This is admin's second and final write path into the control-plane database,
// alongside inject. It writes `instances` and `users` and nothing else. A third
// write target, or a write to any other collection from here, is a design change
// and not a refactor — see the spec's "Admin's control-plane write boundary".
//
// Every function here is called from a customer request, so each one leaves the
// control plane in a consistent state or not at all: the caller unwinds in
// reverse order on failure, and RollbackInstance refuses to delete an instance
// that has users.
package cloudprov

import (
	"context"
	"fmt"

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

// CreateInstance creates a control-plane instance and its owner.
//
// The owner's password hash is COPIED from the HQ account rather than shared.
// HQ remains the single source of truth: a password change there copies the new
// hash to every projected row (see SetPasswordHash), and hqsync repairs any that
// a failed write left stale. The control plane has no local password-change path
// for an hq-sourced row, so there is no competing writer.
//
// On owner-insert failure the instance is rolled back, so a failed provision
// never leaves a slug permanently occupied by an instance nobody owns.
func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
	return CreateInstanceWithID(ctx, uuid.NewString(), name, ownerEmail, ownerPasswordHash, hqUserID)
}

// CreateInstanceWithID provisions a cloud instance under a caller-supplied ID and
// its owner. It backs the paid-cloud flow, where the ID is a placeholder created
// before payment and provisioning runs on the confirmed-payment webhook (see
// provision.CreateInstanceWithID).
//
// It is idempotent, because a webhook can be retried after provisioning partly
// completed: the instance is created only if absent, and the owner only if the
// instance has none yet. A second call therefore converges to the same state
// rather than colliding on the per-instance email unique index.
func CreateInstanceWithID(ctx context.Context, instanceID, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
	inst, err := provision.CreateInstanceWithID(ctx, db.ControlDB(), instanceID, name)
	if err != nil {
		return nil, err
	}

	// A retry that already created the owner must not create a second one.
	if _, err := OwnerUserID(ctx, inst.InstanceID); err == nil {
		return inst, nil
	}

	u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
		ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
	if err != nil {
		if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil {
			return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr)
		}
		return nil, err
	}

	// hq_user_id is what phase 3 uses to find every row projected from one HQ
	// user when its password changes. Set at creation so the owner is not a
	// special case later.
	if _, err := db.Control("users").UpdateOne(ctx,
		bson.M{"user_id": u.UserID},
		bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
		return nil, fmt.Errorf("set hq_user_id: %w", err)
	}

	return inst, nil
}

// DeleteUser removes one control-plane user. Used only to unwind a failed
// provision.
func DeleteUser(ctx context.Context, instanceID, userID string) error {
	_, err := db.Control("users").DeleteOne(ctx,
		bson.M{"instance_id": instanceID, "user_id": userID})
	return err
}

// RollbackInstance deletes an instance that has no users.
func RollbackInstance(ctx context.Context, instanceID string) error {
	return provision.RollbackInstance(ctx, db.ControlDB(), instanceID)
}

// OwnerUserID returns the control-plane user_id of an instance's owner, so a
// caller can unwind a partial provision without re-deriving it.
func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
	var u sharedmodels.User
	err := db.Control("users").FindOne(ctx, bson.M{
		"instance_id": instanceID,
		"role":        sharedmodels.RoleOwner,
	}).Decode(&u)
	if err != nil {
		return "", err
	}
	return u.UserID, nil
}

// GrantUser projects an HQ person into a control-plane instance.
//
// The password hash is copied from customer_users rather than re-derived: HQ
// owns the password, and a grant that asked for a password again would create
// a second credential for one person.
//
// The row is written with auth_source "hq" and hq_user_id set, which is what
// makes the control plane refuse to edit it locally and what lets a password
// change find it later.
func GrantUser(ctx context.Context, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error) {
	u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), instanceID,
		email, passwordHash, role, sharedmodels.AuthHQ)
	if err != nil {
		return nil, err
	}
	if _, err := db.Control("users").UpdateOne(ctx,
		bson.M{"user_id": u.UserID},
		bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
		// Unwind: a projected row with no hq_user_id is invisible to revoke and
		// to password propagation, which is worse than no row at all.
		_, _ = db.Control("users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
		return nil, fmt.Errorf("set hq_user_id: %w", err)
	}
	u.HQUserID = hqUserID
	return u, nil
}

// RevokeUser deletes the projected row for one person in one instance.
//
// Deleting rather than disabling is deliberate: the control plane has no
// concept of a disabled user, and a row that still exists is a row that can
// still sign in.
func RevokeUser(ctx context.Context, instanceID, hqUserID string) error {
	_, err := db.Control("users").DeleteOne(ctx, bson.M{
		"instance_id": instanceID,
		"hq_user_id":  hqUserID,
	})
	return err
}

// SetMemberRole changes a projected user's role inside one instance.
func SetMemberRole(ctx context.Context, instanceID, hqUserID, role string) error {
	if !sharedmodels.ValidRole(role) {
		return fmt.Errorf("invalid role %q", role)
	}
	res, err := db.Control("users").UpdateOne(ctx,
		bson.M{"instance_id": instanceID, "hq_user_id": hqUserID},
		bson.M{"$set": bson.M{"role": role}})
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return fmt.Errorf("no projected user in instance %s", instanceID)
	}
	return nil
}

// CountOtherOwners counts owners of an instance other than one HQ person.
//
// It counts CONTROL-PLANE owners, so an owner created locally inside the
// instance counts too. That matters: refusing to revoke the last HQ owner of
// an instance that has three local owners would be a refusal with no cause.
//
// $ne matches documents where the field is absent, which is exactly how a
// locally-created owner is stored.
func CountOtherOwners(ctx context.Context, instanceID, exceptHQUserID string) (int64, error) {
	return db.Control("users").CountDocuments(ctx, bson.M{
		"instance_id": instanceID,
		"role":        sharedmodels.RoleOwner,
		"hq_user_id":  bson.M{"$ne": exceptHQUserID},
	})
}

// SetPasswordHash writes one hash to every row projected from one HQ person,
// across every instance, and reports how many it changed.
func SetPasswordHash(ctx context.Context, hqUserID, hash string) (int64, error) {
	res, err := db.Control("users").UpdateMany(ctx,
		bson.M{"hq_user_id": hqUserID},
		bson.M{"$set": bson.M{"password_hash": hash}})
	if err != nil {
		return 0, err
	}
	return res.ModifiedCount, nil
}

// ProjectedUsers returns every control-plane row projected from one HQ person.
// hqsync uses it to compare hashes.
func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User, error) {
	cur, err := db.Control("users").Find(ctx, bson.M{"hq_user_id": hqUserID})
	if err != nil {
		return nil, err
	}
	var users []sharedmodels.User
	if err := cur.All(ctx, &users); err != nil {
		return nil, err
	}
	return users, nil
}
