// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later

package updateserver

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strconv"
	"strings"

	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)

func init() {
	db.RegisterModel(new(LicenseKey))
}

// LicenseKey represents an individual key issued from a LicensePackage.
type LicenseKey struct {
	ID                int64              `xorm:"pk autoincr"`
	PackageID         int64              `xorm:"INDEX NOT NULL"`           // FK to license_package
	OwnerID           int64              `xorm:"INDEX NOT NULL"`           // org or user that issued it
	KeyHash           string             `xorm:"UNIQUE NOT NULL"`          // SHA-256 of the raw key (for fast lookup)
	KeyRaw            string             `xorm:"TEXT"`                     // plaintext key (viewable by admins)
	KeyPrefix         string             `xorm:"NOT NULL"`                 // first 8 chars for display
	LicenseeName      string             `xorm:""`                         // customer name
	LicenseeEmail     string             `xorm:""`                         // customer email
	DomainRestriction string             `xorm:"TEXT"`                     // comma-separated allowed domains
	MaxSites          int                `xorm:"NOT NULL DEFAULT 0"`       // 0 = use package default
	PaymentRef        string             `xorm:""`                         // idempotency key from payment system
	IsInternal        bool               `xorm:"NOT NULL DEFAULT false"`   // true = base org/repo key
	IsActive          bool               `xorm:"NOT NULL DEFAULT true"`
	StartsUnix        timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`      // custom start, 0 = creation
	ExpiresUnix       timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`      // 0 = never
	LastHeartbeatUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`      // last successful validation
	FirstUsedUnix     timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`      // first successful validation (for domain lock timer)
	CreatedUnix       timeutil.TimeStamp `xorm:"INDEX CREATED"`
	UpdatedUnix       timeutil.TimeStamp `xorm:"UPDATED"`
}

func (LicenseKey) TableName() string {
	return "license_key"
}

// GenerateKeyString creates a random license key in PREFIX-XXXX-XXXX-XXXX-XXXX format.
// If prefix is empty, defaults to "MOKO".
func GenerateKeyString(prefix string) (string, error) {
	if prefix == "" {
		prefix = "MOKO"
	}
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	h := strings.ToUpper(hex.EncodeToString(b))
	return fmt.Sprintf("%s-%s-%s-%s-%s", prefix, h[0:4], h[4:8], h[8:12], h[12:16]), nil
}

// HashKey returns the SHA-256 hash of a raw key string.
func HashKey(rawKey string) string {
	h := sha256.Sum256([]byte(rawKey))
	return hex.EncodeToString(h[:])
}

// CreateLicenseKey generates a new key, stores it in plaintext and hashed, and returns the raw key.
// The prefix is looked up from the org's update stream config.
func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err error) {
	prefix := ""
	cfg := GetEffectiveConfig(ctx, key.OwnerID, 0)
	if cfg != nil && cfg.KeyPrefix != "" {
		prefix = cfg.KeyPrefix
	}
	rawKey, err = GenerateKeyString(prefix)
	if err != nil {
		return "", fmt.Errorf("GenerateKeyString: %w", err)
	}

	key.KeyHash = HashKey(rawKey)
	key.KeyRaw = rawKey
	key.KeyPrefix = rawKey[:12] + "..."

	if _, err := db.GetEngine(ctx).Insert(key); err != nil {
		return "", err
	}
	return rawKey, nil
}

// CreateLicenseKeyCustom stores a key with a user-provided raw key string.
func CreateLicenseKeyCustom(ctx context.Context, key *LicenseKey, rawKey string) error {
	key.KeyHash = HashKey(rawKey)
	key.KeyRaw = rawKey
	if len(rawKey) > 12 {
		key.KeyPrefix = rawKey[:12] + "..."
	} else {
		key.KeyPrefix = rawKey
	}
	_, err := db.GetEngine(ctx).Insert(key)
	return err
}

// GetLicenseKeyByHash looks up a key by its SHA-256 hash.
func GetLicenseKeyByHash(ctx context.Context, hash string) (*LicenseKey, error) {
	key := new(LicenseKey)
	has, err := db.GetEngine(ctx).Where("key_hash = ?", hash).Get(key)
	if err != nil {
		return nil, err
	}
	if !has {
		return nil, db.ErrNotExist{Resource: "LicenseKey"}
	}
	return key, nil
}

// GetLicenseKeyByID returns a key by its ID.
func GetLicenseKeyByID(ctx context.Context, id int64) (*LicenseKey, error) {
	key := new(LicenseKey)
	has, err := db.GetEngine(ctx).ID(id).Get(key)
	if err != nil {
		return nil, err
	}
	if !has {
		return nil, db.ErrNotExist{Resource: "LicenseKey", ID: id}
	}
	return key, nil
}

// ListLicenseKeys returns all keys for the given owner, master keys first.
func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
	keys := make([]*LicenseKey, 0, 20)
	return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).
		OrderBy("is_internal DESC, created_unix DESC").Find(&keys)
}

// SearchLicenseKeys searches keys for an owner by key prefix/raw, licensee, email, or domain.
func SearchLicenseKeys(ctx context.Context, ownerID int64, query string) ([]*LicenseKey, error) {
	keys := make([]*LicenseKey, 0, 20)
	like := "%" + strings.ToLower(query) + "%"
	return keys, db.GetEngine(ctx).
		Where("owner_id = ?", ownerID).
		And("(LOWER(key_prefix) LIKE ? OR LOWER(key_raw) LIKE ? OR LOWER(licensee_name) LIKE ? OR LOWER(licensee_email) LIKE ? OR LOWER(domain_restriction) LIKE ? OR LOWER(payment_ref) LIKE ?)",
			like, like, like, like, like, like).
		Find(&keys)
}

// ListLicenseKeysByPackage returns all keys for a specific package.
func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseKey, error) {
	keys := make([]*LicenseKey, 0, 20)
	return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys)
}

// GetLicenseKeyByPaymentRef looks up a key by its payment reference (idempotency).
func GetLicenseKeyByPaymentRef(ctx context.Context, paymentRef string) (*LicenseKey, error) {
	if paymentRef == "" {
		return nil, db.ErrNotExist{Resource: "LicenseKey"}
	}
	key := new(LicenseKey)
	has, err := db.GetEngine(ctx).Where("payment_ref = ?", paymentRef).Get(key)
	if err != nil {
		return nil, err
	}
	if !has {
		return nil, db.ErrNotExist{Resource: "LicenseKey"}
	}
	return key, nil
}

// CountKeysByPackage returns the number of keys for a package.
func CountKeysByPackage(ctx context.Context, packageID int64) (int64, error) {
	return db.GetEngine(ctx).Where("package_id = ?", packageID).Count(new(LicenseKey))
}

// UpdateLicenseKey updates a license key.
func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error {
	_, err := db.GetEngine(ctx).ID(key.ID).AllCols().Update(key)
	return err
}

// DeleteLicenseKey permanently removes a license key by ID.
func DeleteLicenseKey(ctx context.Context, id int64) error {
	_, err := db.GetEngine(ctx).ID(id).Delete(new(LicenseKey))
	return err
}

// DeleteExpiredKeys removes keys that expired more than the given duration ago.
func DeleteExpiredKeys(ctx context.Context, olderThanDays int) (int64, error) {
	cutoff := timeutil.TimeStampNow() - timeutil.TimeStamp(int64(olderThanDays)*86400)
	return db.GetEngine(ctx).
		Where("expires_unix > 0 AND expires_unix < ? AND is_internal = ?", cutoff, false).
		Delete(new(LicenseKey))
}

// TouchHeartbeat updates the last heartbeat timestamp for a key.
// On first call, also sets FirstUsedUnix.
func TouchHeartbeat(ctx context.Context, keyID int64) error {
	now := timeutil.TimeStampNow()
	key, err := GetLicenseKeyByID(ctx, keyID)
	if err != nil {
		return err
	}
	key.LastHeartbeatUnix = now
	if key.FirstUsedUnix == 0 {
		key.FirstUsedUnix = now
		_, err = db.GetEngine(ctx).ID(keyID).
			Cols("last_heartbeat_unix", "first_used_unix").
			Update(key)
	} else {
		_, err = db.GetEngine(ctx).ID(keyID).
			Cols("last_heartbeat_unix").
			Update(key)
	}
	return err
}

// ValidateLicenseKey validates a raw key string against the database.
// Returns the key record and its associated package, or an error.
// The domain parameter is optional — when provided, it is checked against
// the key's DomainRestriction list and the MaxSites limit.
// On first heartbeat with a domain, if no DomainRestriction is set, the domain
// is automatically associated as the key's restriction (lock-on-first-use).
func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey, *LicensePackage, error) {
	hash := HashKey(rawKey)
	key, err := GetLicenseKeyByHash(ctx, hash)
	if err != nil {
		return nil, nil, fmt.Errorf("invalid license key")
	}

	if !key.IsActive {
		return nil, nil, fmt.Errorf("license key is deactivated")
	}

	now := timeutil.TimeStampNow()
	if key.StartsUnix > 0 && now < key.StartsUnix {
		return nil, nil, fmt.Errorf("license key not yet active")
	}
	if key.ExpiresUnix > 0 && now > key.ExpiresUnix {
		return nil, nil, fmt.Errorf("license key has expired")
	}

	pkg, err := GetLicensePackageByID(ctx, key.PackageID)
	if err != nil {
		return nil, nil, fmt.Errorf("license package not found")
	}

	if !pkg.IsActive {
		return nil, nil, fmt.Errorf("license package is deactivated")
	}

	// RepoScope validation is done by the caller (update server passes repoID).
	// See ValidateLicenseKeyForRepo below.

	// Domain restriction check — skip for internal/master keys.
	if domain != "" && !key.IsInternal {
		now := timeutil.TimeStampNow()

		if key.DomainRestriction != "" {
			// Domain restriction is set — enforce it.
			allowed := false
			for _, d := range strings.Split(key.DomainRestriction, ",") {
				if strings.EqualFold(strings.TrimSpace(d), domain) {
					allowed = true
					break
				}
			}
			if !allowed {
				// Check if still within the domain lock grace period.
				lockHours := pkg.DomainLockHours
				if lockHours > 0 && key.FirstUsedUnix > 0 {
					lockDeadline := key.FirstUsedUnix + timeutil.TimeStamp(int64(lockHours)*3600)
					if now < lockDeadline {
						// Grace period active — allow and auto-add this domain.
						_ = updateDomainRestriction(ctx, key.ID, domain)
						key.DomainRestriction = key.DomainRestriction + "," + domain
						allowed = true
					}
				}
				if !allowed {
					return nil, nil, fmt.Errorf("domain not allowed for this license key")
				}
			}
		} else {
			// No domain restriction set — auto-associate domain.
			maxSites := key.MaxSites
			if maxSites == 0 {
				maxSites = pkg.MaxSites
			}
			domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain)
			if !domainKnown {
				if maxSites > 0 {
					uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID)
					if err != nil {
						return nil, nil, fmt.Errorf("failed to count domains: %w", err)
					}
					if uniqueDomains >= int64(maxSites) {
						return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
					}
				}
				_ = updateDomainRestriction(ctx, key.ID, domain)
				if key.DomainRestriction == "" {
					key.DomainRestriction = domain
				} else {
					key.DomainRestriction = key.DomainRestriction + "," + domain
				}
			}
		}

		// Site limit check: use key's MaxSites, fall back to package default.
		maxSites := key.MaxSites
		if maxSites == 0 {
			maxSites = pkg.MaxSites
		}
		if maxSites > 0 {
			uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID)
			if err != nil {
				return nil, nil, fmt.Errorf("failed to count domains: %w", err)
			}
			// Allow if this domain is already recorded, or if under the limit.
			domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain)
			if !domainKnown && uniqueDomains >= int64(maxSites) {
				return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
			}
		}
	}

	return key, pkg, nil
}

// ValidateLicenseKeyForRepo validates a key and checks that the package's RepoScope
// includes the given repo. This is the primary entry point for update server validation.
func ValidateLicenseKeyForRepo(ctx context.Context, rawKey, domain string, repoID int64) (*LicenseKey, *LicensePackage, error) {
	key, pkg, err := ValidateLicenseKey(ctx, rawKey, domain)
	if err != nil {
		return nil, nil, err
	}

	// Master/internal keys bypass repo scope.
	if key.IsInternal {
		return key, pkg, nil
	}

	// Check repo scope.
	if pkg.RepoScope != "" && pkg.RepoScope != "all" {
		scopeStr := pkg.RepoScope
		allowed := false

		if strings.HasPrefix(scopeStr, "[") {
			// JSON array format: parse properly to avoid substring matching bugs.
			var ids []int64
			if err := json.Unmarshal([]byte(scopeStr), &ids); err == nil {
				for _, id := range ids {
					if id == repoID {
						allowed = true
						break
					}
				}
			}
		} else {
			// Single repo ID string.
			if id, err := strconv.ParseInt(scopeStr, 10, 64); err == nil {
				allowed = id == repoID
			}
		}

		if !allowed {
			return nil, nil, fmt.Errorf("license key not valid for this repository")
		}
	}

	return key, pkg, nil
}

// updateDomainRestriction appends a domain to a key's DomainRestriction field in the DB.
func updateDomainRestriction(ctx context.Context, keyID int64, domain string) error {
	key, err := GetLicenseKeyByID(ctx, keyID)
	if err != nil {
		return err
	}
	if key.DomainRestriction == "" {
		key.DomainRestriction = domain
	} else {
		key.DomainRestriction = key.DomainRestriction + "," + domain
	}
	_, err = db.GetEngine(ctx).ID(keyID).Cols("domain_restriction").Update(key)
	return err
}

// RenewLicenseKey extends the expiration of a key by the given number of days
// from the current expiry (or from now if already expired/no expiry set).
func RenewLicenseKey(ctx context.Context, keyID int64, days int) error {
	key, err := GetLicenseKeyByID(ctx, keyID)
	if err != nil {
		return err
	}

	now := timeutil.TimeStampNow()
	var base timeutil.TimeStamp
	if key.ExpiresUnix > 0 && key.ExpiresUnix > now {
		// Key still valid — extend from current expiry.
		base = key.ExpiresUnix
	} else {
		// Key expired or has no expiry — extend from now.
		base = now
	}

	key.ExpiresUnix = base + timeutil.TimeStamp(int64(days)*86400)
	key.IsActive = true
	_, err = db.GetEngine(ctx).ID(keyID).Cols("expires_unix", "is_active").Update(key)
	return err
}
