//! Tag presence tracker.
//!
//! Turns a stream of LLRP tag observations into enter/exit events per
//! (EPC, antenna) pair.  A tag is considered "present" from its first
//! observation until `exit_timeout_us` microseconds pass with no new
//! observation on the same antenna.
//!
//! Call `observe()` for every arriving `llrp.TagObservation`, and call
//! `checkExpired()` periodically (e.g. after each receive poll) to fire
//! exit callbacks for tags that have dropped out of view.

const std = @import("std");
const llrp = @import("llrp.zig");

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

/// One RSSI measurement from a continuous inventory scan.
pub const RssiSample = struct {
    rssi_dbm: i8,
    timestamp_us: u64,
};

/// Called on the first observation of a (tag, antenna) pair.
///
/// `epc`          – raw EPC bytes (valid only for the duration of the call)
/// `antenna`      – 1-based antenna port number reported by the reader
/// `timestamp_us` – UTC microseconds of the first observation
pub const OnEnterFn = *const fn (
    epc: []const u8,
    antenna: u16,
    timestamp_us: u64,
) void;

/// Called when a tag has not been seen on an antenna for `exit_timeout_us`.
///
/// `epc`           – raw EPC bytes (valid only for the duration of the call)
/// `antenna`       – 1-based antenna port number
/// `first_seen_us` – UTC microseconds of the first observation this visit
/// `samples`       – RSSI samples collected during the visit; the slice is
///                   freed immediately after this callback returns
pub const OnExitFn = *const fn (
    epc: []const u8,
    antenna: u16,
    first_seen_us: u64,
    samples: []const RssiSample,
) void;

/// Tuning parameters for the tracker.
pub const Options = struct {
    /// How long after the last observation (µs) before a tag is considered
    /// gone and `on_exit` is fired.  Default: 10 seconds.
    exit_timeout_us: u64 = 10_000_000,

    /// Duration of each RSSI averaging window (µs).  All observations
    /// within a window are averaged into a single sample candidate.
    /// Default: 200 ms.
    sample_interval_us: u64 = 200_000,

    /// Minimum change from the last stored sample (dBm) required to
    /// commit a new one.  Intervals whose average falls within this band
    /// are silently dropped.  Default: 2 dBm.
    min_delta_dbm: u8 = 2,
};

// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------

/// HashMap key: (zero-padded EPC, antenna).
/// Bytes beyond `epc_len` are always zeroed so that auto-hashing produces
/// consistent results regardless of how the array was allocated.
const TagKey = struct {
    epc: [32]u8,
    epc_len: u8,
    antenna: u16,

    fn from(obs: llrp.TagObservation) TagKey {
        return .{
            .epc = obs.epc,
            .epc_len = obs.epc_len,
            .antenna = obs.antenna,
        };
    }
};

const TagEntry = struct {
    /// Reader UTC timestamp of the first observation this visit.
    first_seen_us: u64,
    /// Local wall-clock time (µs) of the most recent observation.
    /// Using local clock keeps age calculations consistent with `checkExpired`.
    last_seen_us: u64,
    /// RSSI of the last committed sample; new intervals are compared against
    /// this to decide whether the change is worth storing.
    last_committed_rssi: i8,
    /// Start of the in-progress averaging window (local clock).
    pending_start_us: u64,
    /// Accumulator for the in-progress window.
    pending_sum: i32,
    pending_count: u32,
    rssi_samples: std.ArrayListUnmanaged(RssiSample),
};

// ---------------------------------------------------------------------------
// Tracker
// ---------------------------------------------------------------------------

pub const Tracker = struct {
    allocator: std.mem.Allocator,
    on_enter: OnEnterFn,
    on_exit: OnExitFn,
    opts: Options,
    tags: std.AutoHashMap(TagKey, TagEntry),

    pub fn init(
        allocator: std.mem.Allocator,
        on_enter: OnEnterFn,
        on_exit: OnExitFn,
        opts: Options,
    ) Tracker {
        return .{
            .allocator = allocator,
            .on_enter = on_enter,
            .on_exit = on_exit,
            .opts = opts,
            .tags = std.AutoHashMap(TagKey, TagEntry).init(allocator),
        };
    }

    /// Release all tracked state.  Any tags still in flight are silently
    /// discarded — call `flush()` first if you need exit callbacks for them.
    pub fn deinit(self: *Tracker) void {
        var it = self.tags.valueIterator();
        while (it.next()) |entry| entry.rssi_samples.deinit(self.allocator);
        self.tags.deinit();
    }

    /// Fire `on_exit` for every tag currently tracked, then clear the map.
    /// Useful for graceful shutdown.
    pub fn flush(self: *Tracker) void {
        const now_us: u64 = @intCast(std.time.microTimestamp());
        var it = self.tags.iterator();
        while (it.next()) |kv| {
            const key = kv.key_ptr.*;
            const entry = kv.value_ptr;
            // Force-commit any partial averaging window before reporting.
            self.commitPending(entry, now_us, true) catch {};
            self.on_exit(
                key.epc[0..key.epc_len],
                key.antenna,
                entry.first_seen_us,
                entry.rssi_samples.items,
            );
            entry.rssi_samples.deinit(self.allocator);
        }
        self.tags.clearRetainingCapacity();
    }

    /// Record one tag observation.  Fires `on_enter` the first time a
    /// (tag, antenna) pair is seen within a visit.
    pub fn observe(self: *Tracker, obs: llrp.TagObservation) !void {
        const key = TagKey.from(obs);
        const received_us: u64 = @intCast(std.time.microTimestamp());
        const result = try self.tags.getOrPut(key);

        if (!result.found_existing) {
            // First observation this visit — store as baseline and fire on_enter.
            result.value_ptr.* = .{
                .first_seen_us = obs.timestamp_us,
                .last_seen_us = received_us,
                .last_committed_rssi = obs.rssi_dbm,
                .pending_start_us = received_us,
                .pending_sum = obs.rssi_dbm,
                .pending_count = 1,
                .rssi_samples = .{},
            };
            // Always store the first reading as the baseline sample.
            try result.value_ptr.rssi_samples.append(self.allocator, .{
                .rssi_dbm = obs.rssi_dbm,
                .timestamp_us = obs.timestamp_us,
            });
            self.on_enter(key.epc[0..key.epc_len], key.antenna, obs.timestamp_us);
        } else {
            const entry = result.value_ptr;
            entry.last_seen_us = received_us;
            entry.pending_sum += obs.rssi_dbm;
            entry.pending_count += 1;

            // Commit the averaging window once its duration has elapsed.
            if (received_us -| entry.pending_start_us >= self.opts.sample_interval_us) {
                try self.commitPending(entry, obs.timestamp_us, false);
                entry.pending_start_us = received_us;
            }
        }
    }

    /// Check for tags that have not been seen within `exit_timeout_us` and
    /// fire `on_exit` for each.  Pass the current UTC time in microseconds
    /// (e.g. `@as(u64, @intCast(std.time.microTimestamp()))`).
    pub fn checkExpired(self: *Tracker, now_us: u64) !void {
        // Collect expired keys first; we cannot remove entries while iterating.
        var expired: std.ArrayListUnmanaged(TagKey) = .{};
        defer expired.deinit(self.allocator);

        var it = self.tags.iterator();
        while (it.next()) |kv| {
            const age = now_us -| kv.value_ptr.last_seen_us;
            if (age >= self.opts.exit_timeout_us) {
                try expired.append(self.allocator, kv.key_ptr.*);
            }
        }

        for (expired.items) |key| {
            if (self.tags.fetchRemove(key)) |kv| {
                var entry = kv.value;
                defer entry.rssi_samples.deinit(self.allocator);
                // Force-commit any partial window before reporting.
                try self.commitPending(&entry, now_us, true);
                self.on_exit(
                    key.epc[0..key.epc_len],
                    key.antenna,
                    entry.first_seen_us,
                    entry.rssi_samples.items,
                );
            }
        }
    }

    // -----------------------------------------------------------------------
    // Private helpers
    // -----------------------------------------------------------------------

    /// Average the pending window and store a sample if the result differs
    /// from `last_committed_rssi` by at least `min_delta_dbm`.
    /// When `force` is true the sample is always stored (used on exit to
    /// ensure the final reading is captured).
    /// Resets `pending_sum` and `pending_count` afterwards.
    fn commitPending(
        self: *Tracker,
        entry: *TagEntry,
        timestamp_us: u64,
        force: bool,
    ) !void {
        if (entry.pending_count == 0) return;
        const avg: i8 = @intCast(@divTrunc(entry.pending_sum, @as(i32, @intCast(entry.pending_count))));
        const delta: u8 = @intCast(@min(255, @abs(@as(i16, avg) - @as(i16, entry.last_committed_rssi))));
        if (force or delta >= self.opts.min_delta_dbm) {
            try entry.rssi_samples.append(self.allocator, .{
                .rssi_dbm = avg,
                .timestamp_us = timestamp_us,
            });
            entry.last_committed_rssi = avg;
        }
        entry.pending_sum = 0;
        entry.pending_count = 0;
    }
};
