package repositories

import (
	"context"
	"fmt"
	"strconv"
	"time"

	"nearle/db"
	"nearle/models"

	"github.com/redis/go-redis/v9"
)

// POS terminal presence, in Redis.
//
// ### Why Redis and not a table
//
// A heartbeat is a fact with an expiry date. Written to Postgres it needs a
// row per till updated twice a minute — around 288,000 writes a day across a
// hundred terminals — and a reaper job to mark a till offline once it stops,
// because a row that says "online" has no way of ageing out on its own.
//
// A Redis key with a TTL does the ageing for free. A till that loses power
// stops refreshing, the key expires, and it disappears from the board without
// anything having to notice. That is the whole design.
//
// ### Keys
//
//	pos:terminal:{terminalcode}         HASH, TTL 90s   — one till's state
//	pos:location:{locationid}:terminals SET,  no TTL    — which tills a shop has
//
// The set has no TTL on purpose, mirroring how `city:{tenantid}:active_deliveries`
// is treated in the express backend: it is an index of what exists, not a claim
// that any of it is alive right now. Membership means "this till has been seen
// here"; liveness is whether the hash still exists.
const (
	// Three missed heartbeats. Two would make an ordinary GPRS hiccup look like
	// a dead till; five would take two and a half minutes to notice a real one.
	posPresenceTTL = 90 * time.Second

	posTerminalKeyFmt = "pos:terminal:%s"
	posLocationKeyFmt = "pos:location:%s:terminals"
)

type PosPresenceRepository interface {
	Record(ctx context.Context, health models.PosHealth) error
	Terminal(ctx context.Context, terminalID string) (map[string]string, error)
	Location(ctx context.Context, locationID string) ([]map[string]string, error)
}

type posPresenceRepository struct{}

func NewPosPresenceRepository() PosPresenceRepository { return &posPresenceRepository{} }

// Record writes one heartbeat and refreshes its TTL.
func (r *posPresenceRepository) Record(ctx context.Context, health models.PosHealth) error {
	if db.Rdb == nil {
		return fmt.Errorf("redis is not configured")
	}
	if health.Terminalid == "" {
		return fmt.Errorf("heartbeat has no terminal id")
	}

	terminalKey := fmt.Sprintf(posTerminalKeyFmt, health.Terminalid)

	fields := map[string]any{
		"terminal_id": health.Terminalid,
		"location_id": health.Locationid,
		"store_name":  health.Storename,
		"app_version": health.Appversion,
		"status":      health.Status,

		"pending_bills":         health.Pendingbills,
		"pending_registrations": health.Pendingregistrations,
		"oldest_pending_at":     health.Oldestpendingat,

		"today_bills":  health.Todaybills,
		"today_amount": health.Todayamount,
		"last_bill_at": health.Lastbillat,

		"reported_at": health.Reportedat,
		// Stamped here as well as at the till. The two disagreeing by more than
		// a few seconds means the terminal's clock is wrong — which matters,
		// because bills are filed under the business date the till decided.
		"received_at": time.Now().UTC().Format(time.RFC3339),
	}

	// Device readings only when the till actually reported them. A build that
	// does not collect battery level must not leave one behind saying 0%.
	if health.Batterylevel != nil {
		fields["battery_level"] = *health.Batterylevel
	}
	if health.Batterycharging != nil {
		fields["battery_charging"] = *health.Batterycharging
	}
	if health.Storagefreemb != nil {
		fields["storage_free_mb"] = *health.Storagefreemb
	}
	if health.Printerreachable != nil {
		fields["printer_reachable"] = *health.Printerreachable
	}
	if health.Drawerstatus != nil {
		fields["drawer_status"] = *health.Drawerstatus
	}

	// HSet leaves untouched fields in place, so a reading that stops being
	// reported would otherwise linger for ever at its last value. Clearing the
	// absent ones keeps the hash honest about what this till currently knows.
	stale := make([]string, 0, 5)
	for field, reported := range map[string]bool{
		"battery_level":     health.Batterylevel != nil,
		"battery_charging":  health.Batterycharging != nil,
		"storage_free_mb":   health.Storagefreemb != nil,
		"printer_reachable": health.Printerreachable != nil,
		"drawer_status":     health.Drawerstatus != nil,
	} {
		if !reported {
			stale = append(stale, field)
		}
	}

	pipe := db.Rdb.TxPipeline()
	pipe.HSet(ctx, terminalKey, fields)
	if len(stale) > 0 {
		pipe.HDel(ctx, terminalKey, stale...)
	}
	pipe.Expire(ctx, terminalKey, posPresenceTTL)

	if health.Locationid != "" {
		// No TTL: this is the list of tills a shop has, not a claim that any of
		// them is alive. Liveness is whether the hash above still exists.
		pipe.SAdd(ctx, fmt.Sprintf(posLocationKeyFmt, health.Locationid), health.Terminalid)
	}

	_, err := pipe.Exec(ctx)
	return err
}

// Terminal returns one till's last known state, or nil if it has gone quiet.
func (r *posPresenceRepository) Terminal(ctx context.Context, terminalID string) (map[string]string, error) {
	if db.Rdb == nil {
		return nil, fmt.Errorf("redis is not configured")
	}

	fields, err := db.Rdb.HGetAll(ctx, fmt.Sprintf(posTerminalKeyFmt, terminalID)).Result()
	if err != nil && err != redis.Nil {
		return nil, err
	}
	if len(fields) == 0 {
		// Expired or never seen. Both mean "not reporting", which is what the
		// caller needs to know; distinguishing them would need a durable record
		// this deliberately does not keep.
		return nil, nil
	}
	return fields, nil
}

// Location returns every till registered at a shop, live or dark.
//
// A till whose key has expired comes back as a stub with status "offline"
// rather than being omitted. Omitting it would make a dead terminal
// indistinguishable from one that was never installed — and the dead one is
// precisely what somebody is looking for.
func (r *posPresenceRepository) Location(ctx context.Context, locationID string) ([]map[string]string, error) {
	if db.Rdb == nil {
		return nil, fmt.Errorf("redis is not configured")
	}

	members, err := db.Rdb.SMembers(ctx, fmt.Sprintf(posLocationKeyFmt, locationID)).Result()
	if err != nil && err != redis.Nil {
		return nil, err
	}

	out := make([]map[string]string, 0, len(members))
	for _, terminalID := range members {
		fields, err := r.Terminal(ctx, terminalID)
		if err != nil {
			return nil, err
		}
		if fields == nil {
			fields = map[string]string{
				"terminal_id": terminalID,
				"location_id": locationID,
				"status":      "offline",
				// Says why it is being reported offline, rather than leaving a
				// reader to guess whether the till said so or simply vanished.
				"reason": "no heartbeat within " + strconv.Itoa(int(posPresenceTTL.Seconds())) + "s",
			}
		}
		out = append(out, fields)
	}

	return out, nil
}
