package devices

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"time"

	"github.com/wangjia/pangolin/server/internal/apierr"
)

// CredentialRevoker abstracts the node-side credential recall triggered when a
// device is removed.
//
// The data-plane credential model is per-user (users.dp_uuid, doc/02 §3.2): the
// node side has no device dimension, so "revoke a device" is realised as
// "recall/re-issue the owning user's credential". The interface is defined here
// (consumer side) to avoid an import cycle with the nodes package; module #5's
// nodes.Hub will satisfy it, with RevokeForUser fronting Hub.Push(RevokeCredential).
type CredentialRevoker interface {
	// RevokeForUser instructs node agents to recall (and lazily re-issue) the
	// data-plane credential for userID. reason is a short machine tag, e.g.
	// "device_deleted", used for audit/telemetry on the node side.
	RevokeForUser(ctx context.Context, userID int64, reason string) error
}

// NoopRevoker is the default CredentialRevoker used until module #5 lands.
// It records the last call so callers/tests can assert on it.
type NoopRevoker struct {
	Calls []RevokeCall
}

// RevokeCall captures the arguments of a RevokeForUser invocation.
type RevokeCall struct {
	UserID int64
	Reason string
}

// RevokeForUser implements CredentialRevoker by recording the call.
func (n *NoopRevoker) RevokeForUser(_ context.Context, userID int64, reason string) error {
	n.Calls = append(n.Calls, RevokeCall{UserID: userID, Reason: reason})
	return nil
}

// Service implements the devices business logic.
type Service struct {
	store   *Store
	revoker CredentialRevoker
}

// NewService creates a Service. If revoker is nil a NoopRevoker is used so the
// module can be wired and tested before module #5 (nodes.Hub) is available.
func NewService(store *Store, revoker CredentialRevoker) *Service {
	if revoker == nil {
		revoker = &NoopRevoker{}
	}
	return &Service{store: store, revoker: revoker}
}

// Device is the API representation of a registered device.
type Device struct {
	UUID     string  `json:"uuid"`
	Name     string  `json:"name"`
	Platform string  `json:"platform"`
	LastSeen *string `json:"last_seen"` // RFC 3339 UTC; null when never seen
}

func toAPIDevice(d DeviceRow) Device {
	out := Device{UUID: d.UUID, Name: d.Name, Platform: d.Platform}
	if d.LastSeen.Valid {
		s := d.LastSeen.Time.UTC().Format(time.RFC3339)
		out.LastSeen = &s
	}
	return out
}

// ListDevices returns the user's devices.
func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *apierr.Error) {
	rows, err := svc.store.ListByUser(ctx, userID)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	out := make([]Device, 0, len(rows))
	for _, r := range rows {
		out = append(out, toAPIDevice(r))
	}
	return out, nil
}

// RegisterInput carries the inputs for implicit device registration, called by
// nodes.connect when a client presents a device_id.
type RegisterInput struct {
	UserID     int64
	DeviceUUID string
	Name       string // client-reported, may come from UA; truncated to 64 runes
	Platform   string // ios | android | windows | macos
	MaxDevices int    // plan cap, from the resolved Plan (PlanFromCtx)
}

// RegisterIfAbsent registers a device on first sight and refreshes last_seen on
// subsequent sights. The device count is checked against MaxDevices before
// inserting a brand-new device. Per-user serialization is achieved by locking
// the users row for the duration of the transaction.
func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*Device, *apierr.Error) {
	uuid := strings.TrimSpace(in.DeviceUUID)
	if uuid == "" {
		return nil, apierr.ErrBadRequest
	}
	platform, ok := normalizePlatform(in.Platform)
	if !ok {
		return nil, apierr.ErrBadRequest
	}
	name := normalizeName(in.Name, platform)

	tx, err := svc.store.BeginTx(ctx)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()

	// Lock the owning user to serialize concurrent registrations.
	exists, status, err := svc.store.lockUser(ctx, tx, in.UserID)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	if !exists {
		return nil, apierr.ErrUnauthorized
	}
	if status == "banned" {
		return nil, apierr.ErrAccountBanned
	}

	existing, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	if existing != nil {
		if existing.UserID != in.UserID {
			// UUID is client-generated; a collision across users is treated as
			// a conflict rather than silently rebinding the device.
			return nil, apierr.ErrForbidden
		}
		if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID); err != nil {
			return nil, apierr.ErrInternal
		}
		if err := tx.Commit(); err != nil {
			return nil, apierr.ErrInternal
		}
		committed = true
		d := toAPIDevice(*existing)
		return &d, nil
	}

	// New device: enforce the plan device cap.
	count, err := svc.store.countDevicesTx(ctx, tx, in.UserID)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	if in.MaxDevices > 0 && count >= in.MaxDevices {
		return nil, errDeviceLimit(in.MaxDevices)
	}

	row, err := svc.store.insertDeviceTx(ctx, tx, uuid, in.UserID, name, platform)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	if err := svc.store.writeAuditLogTx(ctx, tx,
		fmt.Sprintf("user:%d", in.UserID), "device.register", "device:"+uuid,
		deviceAuditMeta(name, platform)); err != nil {
		_ = err // audit failure must not abort the business transaction
	}
	if err := tx.Commit(); err != nil {
		return nil, apierr.ErrInternal
	}
	committed = true

	d := toAPIDevice(*row)
	return &d, nil
}

// DeleteDevice hard-deletes a device (transactionally, with an audit_log entry)
// and then triggers per-user credential recall on the node side.
//
//   - device not found            → 404 NOT_FOUND
//   - device owned by another user → 403 FORBIDDEN (does not delete)
func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID string) *apierr.Error {
	uuid := strings.TrimSpace(deviceUUID)
	if uuid == "" {
		return apierr.ErrBadRequest
	}

	tx, err := svc.store.BeginTx(ctx)
	if err != nil {
		return apierr.ErrInternal
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()

	dev, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid)
	if err != nil {
		return apierr.ErrInternal
	}
	if dev == nil {
		return apierr.ErrNotFound
	}
	if dev.UserID != userID {
		return apierr.ErrForbidden
	}

	if err := svc.store.deleteDeviceTx(ctx, tx, dev.ID); err != nil {
		return apierr.ErrInternal
	}
	if err := svc.store.writeAuditLogTx(ctx, tx,
		fmt.Sprintf("user:%d", userID), "device.delete", "device:"+uuid,
		deviceAuditMeta(dev.Name, dev.Platform)); err != nil {
		_ = err // audit failure must not abort the delete
	}
	if err := tx.Commit(); err != nil {
		return apierr.ErrInternal
	}
	committed = true

	// Recall the user's data-plane credential. Per the dp_uuid model this is a
	// per-user operation (no device dimension on the node side). Best-effort:
	// the device is already removed; a transient revoke failure is reconciled
	// by the node agent's periodic resync, so we do not fail the request.
	if err := svc.revoker.RevokeForUser(ctx, userID, "device_deleted"); err != nil {
		_ = err
	}
	return nil
}

// SubscriptionInfo is the /v1/me subscription summary provided by this module.
type SubscriptionInfo struct {
	PlanCode  string  `json:"plan_code"`
	ExpiresAt *string `json:"expires_at"` // RFC 3339 UTC; null for free fallback
	Source    string  `json:"source"`
}

// SubscriptionSummary resolves the user's effective plan and returns the /v1/me
// subscription summary.
func (svc *Service) SubscriptionSummary(ctx context.Context, userID int64) (*SubscriptionInfo, *apierr.Error) {
	p, apiErr := svc.ResolvePlan(ctx, userID)
	if apiErr != nil {
		return nil, apiErr
	}
	info := &SubscriptionInfo{PlanCode: p.PlanCode, Source: p.Source}
	if p.ExpiresAt != nil {
		s := p.ExpiresAt.UTC().Format(time.RFC3339)
		info.ExpiresAt = &s
	}
	return info, nil
}

// ResolvePlan loads the user's status + subscriptions and resolves the effective
// plan. It is the shared entry point used by SubscriptionMiddleware and
// SubscriptionSummary.
func (svc *Service) ResolvePlan(ctx context.Context, userID int64) (Plan, *apierr.Error) {
	status, exists, err := svc.store.GetUserStatus(ctx, userID)
	if err != nil {
		return Plan{}, apierr.ErrInternal
	}
	if !exists {
		return Plan{}, apierr.ErrUnauthorized
	}
	subs, err := svc.store.GetSubscriptions(ctx, userID)
	if err != nil {
		return Plan{}, apierr.ErrInternal
	}
	free, err := svc.store.GetFreePlan(ctx)
	if err != nil {
		return Plan{}, apierr.ErrInternal
	}
	return resolveEffectivePlan(time.Now().UTC(), status, subs, free)
}

// resolveEffectivePlan is the pure plan-resolution rule (no I/O), unit-tested
// directly:
//
//   - banned account                         → ErrAccountBanned
//   - among subscriptions with expires_at>now, pick the highest tier;
//     ties broken by the latest expiry
//   - no active subscription                 → free fallback (ExpiresAt nil)
//
// now must be in UTC. The boundary is strict: expires_at == now counts as expired.
func resolveEffectivePlan(now time.Time, status string, subs []effSub, free Plan) (Plan, *apierr.Error) {
	if status == "banned" {
		return Plan{}, apierr.ErrAccountBanned
	}

	var best *effSub
	for i := range subs {
		s := &subs[i]
		if !s.ExpiresAt.After(now) {
			continue // expired (strict UTC boundary)
		}
		if best == nil {
			best = s
			continue
		}
		bt, st := planTier(best.PlanCode), planTier(s.PlanCode)
		if st > bt || (st == bt && s.ExpiresAt.After(best.ExpiresAt)) {
			best = s
		}
	}

	if best == nil {
		return free, nil
	}

	exp := best.ExpiresAt.UTC()
	p := Plan{
		PlanCode:   best.PlanCode,
		ExpiresAt:  &exp,
		MaxDevices: best.MaxDevices,
		AdGate:     best.AdGate,
		Source:     best.Source,
	}
	if best.DailyMinutes.Valid {
		m := int(best.DailyMinutes.Int64)
		p.DailyMinutes = &m
	}
	return p, nil
}

// --------------------------------------------------------------------------
// helpers
// --------------------------------------------------------------------------

// normalizePlatform validates/canonicalizes a platform string against the
// devices.platform ENUM (ios/android/windows/macos). Note: the OpenAPI Device
// schema also lists "linux", but the DB ENUM (migration #1) does not include
// it, so "linux" is rejected until the schema is widened.
func normalizePlatform(s string) (string, bool) {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "ios":
		return "ios", true
	case "android":
		return "android", true
	case "windows":
		return "windows", true
	case "macos":
		return "macos", true
	}
	return "", false
}

// normalizeName trims the client-reported name, falls back to the platform when
// empty, and truncates to the devices.name VARCHAR(64) limit (by rune).
func normalizeName(name, platform string) string {
	name = strings.TrimSpace(name)
	if name == "" {
		name = platform
	}
	r := []rune(name)
	if len(r) > 64 {
		name = string(r[:64])
	}
	return name
}

// deviceAuditMeta builds a compact JSON meta blob for audit_log.
func deviceAuditMeta(name, platform string) string {
	b, _ := json.Marshal(map[string]string{"name": name, "platform": platform})
	return string(b)
}

// errDeviceLimit builds a bilingual device-cap error that embeds the limit.
func errDeviceLimit(max int) *apierr.Error {
	return &apierr.Error{
		Code:      "DEVICE_LIMIT_EXCEEDED",
		MessageZH: fmt.Sprintf("设备数量已达上限（%d 台），请先在其他设备上退出后重试", max),
		MessageEn: fmt.Sprintf("Device limit reached (%d devices). Please remove another device and try again.", max),
	}
}
