package store

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"log"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/mrhid6/vantage/shared/indexes"
	sharedmodels "github.com/mrhid6/vantage/shared/models"
	"github.com/mrhid6/vantage/shared/provision"
	"github.com/mrhid6/vantage/sitesvc/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
	"go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring"
	"golang.org/x/crypto/bcrypt"
)

const PendingTTL = 24 * time.Hour

// ErrEmailTaken and ErrNameRejected are aliases of the shared errors so
// errors.Is keeps working for existing callers in internal/api.
var (
	ErrEmailTaken   = provision.ErrEmailTaken
	ErrBadToken     = errors.New("verification link is invalid or has expired")
	ErrNameRejected = provision.ErrNameRejected
)

var database *mongo.Database

func Connect(uri string) error {
	cs, err := connstring.ParseAndValidate(uri)
	if err != nil {
		return fmt.Errorf("parse MONGO_URI: %w", err)
	}
	if cs.Database == "" {
		return errors.New("MONGO_URI must name a database, e.g. mongodb://host:27017/vantage")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	client, err := mongo.Connect(options.Client().ApplyURI(uri))
	if err != nil {
		return err
	}
	if err := client.Ping(ctx, nil); err != nil {
		return err
	}
	database = client.Database(cs.Database)
	return nil
}

func DatabaseName() string {
	if database == nil {
		return ""
	}
	return database.Name()
}

func col(name string) *mongo.Collection { return database.Collection(name) }

func EnsureIndexes() error {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// users.email and instances.slug are declared in the shared module so both
	// services agree. Re-declaring at boot means sitesvc does not depend on the
	// control plane having started first.
	if err := indexes.EnsureCoreIndexes(ctx, database); err != nil {
		return err
	}

	_, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{
		{
			Keys:    bson.D{{Key: "token_hash", Value: 1}},
			Options: options.Index().SetUnique(true),
		},
		{Keys: bson.D{{Key: "email", Value: 1}}},

		{
			Keys:    bson.D{{Key: "expires_at", Value: 1}},
			Options: options.Index().SetExpireAfterSeconds(0),
		},
	})
	if err != nil {
		return fmt.Errorf("pending signup indexes: %w", err)
	}
	return nil
}

// RequireMigratedDatabase refuses to start against a control-plane database
// that has not run migration 0004.
//
// Provisioning into `orgs` while the control plane reads `instances` would
// create tenants nobody can see — the exact skew failure the shared module was
// built to prevent. Failing to start is strictly better.
func RequireMigratedDatabase(ctx context.Context) error {
	names, err := database.ListCollectionNames(ctx, bson.M{})
	if err != nil {
		return fmt.Errorf("list collections: %w", err)
	}

	var hasInstances, hasOrgs bool
	for _, n := range names {
		switch n {
		case "instances":
			hasInstances = true
		case "orgs":
			hasOrgs = true
		}
	}

	// A brand-new database has neither. That is fine — whichever service starts
	// first creates `instances`.
	if !hasInstances && !hasOrgs {
		return nil
	}
	if !hasInstances {
		return errors.New("instances collection not found; deploy the control plane first")
	}
	return nil
}

func EmailTaken(ctx context.Context, email string) (bool, error) {
	n, err := col("users").CountDocuments(ctx, bson.M{"email": email})
	return n > 0, err
}

func CreatePending(ctx context.Context, instanceName, email, password string) (string, error) {
	if _, err := provision.BaseSlug(instanceName); err != nil {
		return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
	}

	taken, err := EmailTaken(ctx, email)
	if err != nil {
		return "", err
	}
	if taken {
		return "", ErrEmailTaken
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(password), provision.BcryptCost)
	if err != nil {
		return "", err
	}

	raw, err := randomToken()
	if err != nil {
		return "", err
	}

	if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil {
		return "", err
	}

	now := time.Now().UTC()
	pending := models.PendingSignup{
		PendingID:    uuid.NewString(),
		InstanceName: strings.TrimSpace(instanceName),
		Email:        email,
		PasswordHash: string(hash),
		TokenHash:    hashToken(raw),
		CreatedAt:    now,
		ExpiresAt:    now.Add(PendingTTL),
	}
	if _, err := col("site_pending_signups").InsertOne(ctx, pending); err != nil {
		return "", err
	}
	return raw, nil
}

func Verify(ctx context.Context, rawToken string) (*sharedmodels.Instance, error) {
	var pending models.PendingSignup
	err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
		"token_hash": hashToken(rawToken),
		"expires_at": bson.M{"$gt": time.Now().UTC()},
	}).Decode(&pending)
	if errors.Is(err, mongo.ErrNoDocuments) {
		return nil, ErrBadToken
	}
	if err != nil {
		return nil, err
	}

	inst, err := provision.CreateInstance(ctx, database, pending.InstanceName)
	if err != nil {
		return nil, err
	}

	// The password was hashed when the signup was recorded; only the hash
	// survives to this point.
	_, err = provision.CreateUserWithHash(ctx, database, inst.InstanceID, pending.Email,
		pending.PasswordHash, sharedmodels.RoleOwner, "local")
	if err != nil {
		// Leaving an instance behind would permanently occupy a slug nobody owns.
		if rbErr := provision.RollbackInstance(ctx, database, inst.InstanceID); rbErr != nil {
			log.Printf("verify: failed to roll back instance %s: %v", inst.InstanceID, rbErr)
		}
		return nil, err
	}

	return inst, nil
}

func randomToken() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

func hashToken(raw string) string {
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}
