//! View model joining `analytics/observations` engine output with the
//! `data/journal` acknowledgments file. Produces a flat list of
//! `FindingRow`s ready for both the CLI findings table and the TUI
//! review tab.
//!
//! ## Responsibility split
//!
//! - The **engine** (`analytics/observations.zig`) produces a
//!   `CheckPanel` whose `pending` slice has one entry per registered
//!   check, each with a `CheckResult` of `pass`/`warn`/`flag`/`skipped`/`err`.
//! - The **journal** (`data/Journal.zig`) holds the user's
//!   acknowledgments — durable records keyed by `(observation, target)`.
//! - This **view** matches the two: every `Observation` in a
//!   `warn`/`flag` result becomes a `FindingRow`. The row is marked
//!   acked iff a `Journal.Entry` exists with `state == .acknowledged`
//!   and matching `(observation, target)`.
//!
//! ## Lifetime
//!
//! `FindingRow.text` and friends are **borrowed** from the panel and
//! journal — the view does NOT copy strings. This keeps allocation
//! cheap (one slice for the rows array) and is safe because callers
//! always hold the panel and journal alive for the entire render
//! frame. `FindingsView.deinit` only frees the rows slice.
//!
//! ## Filtering
//!
//! Resolved entries (state == `.resolved`) are never shown — by
//! definition, the engine no longer emits the finding, so there's
//! nothing to suppress. Only `acknowledged` rows can be filtered out
//! via `show_acked = false`.

const std = @import("std");
const observations = @import("../analytics/observations.zig");
const Journal = @import("../data/Journal.zig");

const Observation = observations.Observation;
const Severity = observations.Severity;
const CheckPanel = observations.CheckPanel;

/// One displayable finding. Borrows all string data from the
/// underlying `Observation` and (optionally) the matching journal
/// entry. Lifetime is bounded by the panel + journal.
pub const FindingRow = struct {
    severity: Severity,
    /// Borrowed from `Observation.kind`.
    kind: []const u8,
    /// Borrowed from `Observation.target`.
    target: []const u8,
    /// Borrowed from `Observation.text`.
    text: []const u8,
    /// True iff a journal entry matches `(kind, target)` with state
    /// `.acknowledged`. When true, `ack_entry` is non-null.
    is_acked: bool,
    /// The matching journal entry (when `is_acked == true`), so
    /// renderers can pull notes/acknowledged_at out without re-doing
    /// the lookup. Null when no ack matches.
    ack_entry: ?*const Journal.Entry = null,
};

/// Result of `build`. Owns only the `rows` slice; all string data
/// is borrowed.
pub const FindingsView = struct {
    rows: []FindingRow,
    /// Number of un-acked findings (severity warn or flag, not
    /// suppressed). Independent of `show_acked` — counts the underlying
    /// engine output.
    total_active: usize,
    /// Number of findings whose journal entry is in `.acknowledged`
    /// state.
    total_acked: usize,
    /// Number of journal entries currently in `.resolved` state. These
    /// are never rendered as rows; surfaced only for the header line
    /// ("3 active, 1 acked, 2 resolved").
    total_resolved: usize,

    pub fn deinit(self: *FindingsView, allocator: std.mem.Allocator) void {
        allocator.free(self.rows);
        self.* = undefined;
    }
};

/// Build a `FindingsView` from a panel + journal. `show_acked`
/// controls whether acked findings appear in `rows` (they're always
/// counted in `total_acked`).
pub fn build(
    allocator: std.mem.Allocator,
    panel: *const CheckPanel,
    journal: *const Journal,
    show_acked: bool,
) !FindingsView {
    var rows = std.ArrayList(FindingRow).empty;
    errdefer rows.deinit(allocator);

    var total_active: usize = 0;
    var total_acked: usize = 0;

    for (panel.pending) |pc| {
        const obs_slice: []const Observation = switch (pc.state) {
            .complete => |r| switch (r) {
                .warn => |o| o,
                .flag => |o| o,
                .pass, .skipped, .err => continue,
            },
            // Still running — no findings to show yet. The caller
            // rebuilds the view when the panel completes (TUI
            // polls via tick; CLI awaits before building).
            .pending => continue,
        };

        for (obs_slice) |obs| {
            const ack = findAck(journal, obs.kind, obs.target);
            const is_acked = ack != null;

            if (is_acked) {
                total_acked += 1;
            } else {
                total_active += 1;
            }

            if (is_acked and !show_acked) continue;

            try rows.append(allocator, .{
                .severity = obs.severity,
                .kind = obs.kind,
                .target = obs.target,
                .text = obs.text,
                .is_acked = is_acked,
                .ack_entry = ack,
            });
        }
    }

    // Count resolved entries for the header. These never produce rows
    // — the engine's failure to re-emit the finding is what marks
    // them resolved — but we surface the count so the user knows
    // their journal still tracks them.
    var total_resolved: usize = 0;
    for (journal.entries) |*e| {
        if (e.ack.state == .resolved) total_resolved += 1;
    }

    return .{
        .rows = try rows.toOwnedSlice(allocator),
        .total_active = total_active,
        .total_acked = total_acked,
        .total_resolved = total_resolved,
    };
}

/// Look up a journal entry by `(observation, target)`, returning
/// only entries currently in `.acknowledged` state. Returns null
/// when no match.
///
/// Linear scan is fine for realistic journal sizes (tens of entries).
/// If a portfolio ever accumulates hundreds of acks we can index by
/// (observation, target) at load time.
fn findAck(journal: *const Journal, observation: []const u8, target: []const u8) ?*const Journal.Entry {
    for (journal.entries) |*e| {
        if (e.ack.state != .acknowledged) continue;
        if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
        if (!std.mem.eql(u8, e.ack.target, target)) continue;
        return e;
    }
    return null;
}

// ── Tests ─────────────────────────────────────────────────────

const testing = std.testing;
const Date = @import("../Date.zig");

fn makeObs(kind: []const u8, target: []const u8, text: []const u8) Observation {
    return .{
        .severity = .warn,
        .kind = kind,
        .target = target,
        .text = text,
    };
}

fn makePanel(allocator: std.mem.Allocator, obs_slice: []const Observation) !CheckPanel {
    // Allocate a single-check panel with the supplied observations as
    // a `warn` result. Test-only helper. Dupes all strings so that
    // `panel.deinit`'s `freeObservations` path (which calls
    // `allocator.free` on each string) operates on owned memory rather
    // than caller-supplied literals.
    const owned_obs = try allocator.alloc(Observation, obs_slice.len);
    errdefer allocator.free(owned_obs);
    for (obs_slice, 0..) |o, i| {
        owned_obs[i] = .{
            .severity = o.severity,
            .kind = try allocator.dupe(u8, o.kind),
            .target = try allocator.dupe(u8, o.target),
            .text = try allocator.dupe(u8, o.text),
        };
    }

    const check_singleton = struct {
        const c: observations.Check = .{
            .name = "test_check",
            .label = "Test Check",
            // SAFETY: test-only Check; tests fabricate panels with
            // pre-baked CheckResults via `state = .{ .complete = ... }`,
            // so `runChecks` never dispatches via this fn pointer.
            .run = undefined,
        };
    };

    const pending = try allocator.alloc(observations.PendingCheck, 1);
    pending[0] = .{
        .check = &check_singleton.c,
        .state = .{ .complete = .{ .warn = owned_obs } },
    };
    return .{ .allocator = allocator, .io = std.testing.io, .pending = pending };
}

fn makeJournalWithAck(
    allocator: std.mem.Allocator,
    observation: []const u8,
    target: []const u8,
    state: Journal.State,
) !Journal {
    const obs_dup = try allocator.dupe(u8, observation);
    const tgt_dup = try allocator.dupe(u8, target);
    const empty_notes = try allocator.alloc([]const u8, 0);
    const entries = try allocator.alloc(Journal.Entry, 1);
    entries[0] = .{
        .ack = .{
            .observation = obs_dup,
            .target = tgt_dup,
            .acknowledged_at = Date.fromYmd(2026, 1, 1),
            .state = state,
        },
        .notes = empty_notes,
    };
    return .{ .allocator = allocator, .entries = entries };
}

fn makeEmptyJournal(allocator: std.mem.Allocator) !Journal {
    const entries = try allocator.alloc(Journal.Entry, 0);
    return .{ .allocator = allocator, .entries = entries };
}

test "build: unacked finding produces a row, no ack_entry" {
    const obs = [_]Observation{
        makeObs("position_concentration", "NVDA", "NVDA at 18%"),
    };
    var panel = try makePanel(testing.allocator, &obs);
    defer panel.deinit();

    var journal = try makeEmptyJournal(testing.allocator);
    defer journal.deinit();

    var view = try build(testing.allocator, &panel, &journal, false);
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 1), view.rows.len);
    try testing.expectEqual(@as(usize, 1), view.total_active);
    try testing.expectEqual(@as(usize, 0), view.total_acked);
    try testing.expect(!view.rows[0].is_acked);
    try testing.expect(view.rows[0].ack_entry == null);
}

test "build: acked finding suppressed when show_acked is false" {
    const obs = [_]Observation{
        makeObs("position_concentration", "NVDA", "NVDA at 18%"),
    };
    var panel = try makePanel(testing.allocator, &obs);
    defer panel.deinit();

    var journal = try makeJournalWithAck(
        testing.allocator,
        "position_concentration",
        "NVDA",
        .acknowledged,
    );
    defer journal.deinit();

    var view = try build(testing.allocator, &panel, &journal, false);
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 0), view.rows.len);
    try testing.expectEqual(@as(usize, 0), view.total_active);
    try testing.expectEqual(@as(usize, 1), view.total_acked);
}

test "build: acked finding rendered when show_acked is true" {
    const obs = [_]Observation{
        makeObs("position_concentration", "NVDA", "NVDA at 18%"),
    };
    var panel = try makePanel(testing.allocator, &obs);
    defer panel.deinit();

    var journal = try makeJournalWithAck(
        testing.allocator,
        "position_concentration",
        "NVDA",
        .acknowledged,
    );
    defer journal.deinit();

    var view = try build(testing.allocator, &panel, &journal, true);
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 1), view.rows.len);
    try testing.expect(view.rows[0].is_acked);
    try testing.expect(view.rows[0].ack_entry != null);
}

test "build: resolved entries don't filter findings" {
    // Engine emits a finding for NVDA. Journal has a *resolved* entry
    // for NVDA. The finding should NOT be suppressed — resolved means
    // "engine stopped emitting it last time", but here it's emitting
    // again.
    const obs = [_]Observation{
        makeObs("position_concentration", "NVDA", "NVDA at 18%"),
    };
    var panel = try makePanel(testing.allocator, &obs);
    defer panel.deinit();

    var journal = try makeJournalWithAck(
        testing.allocator,
        "position_concentration",
        "NVDA",
        .resolved,
    );
    defer journal.deinit();

    var view = try build(testing.allocator, &panel, &journal, false);
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 1), view.rows.len);
    try testing.expect(!view.rows[0].is_acked);
    try testing.expectEqual(@as(usize, 1), view.total_resolved);
}

test "build: target mismatch — different symbol doesn't match ack" {
    const obs = [_]Observation{
        makeObs("position_concentration", "NVDA", "NVDA at 18%"),
        makeObs("position_concentration", "AAPL", "AAPL at 17%"),
    };
    var panel = try makePanel(testing.allocator, &obs);
    defer panel.deinit();

    // Ack only NVDA; AAPL should still appear.
    var journal = try makeJournalWithAck(
        testing.allocator,
        "position_concentration",
        "NVDA",
        .acknowledged,
    );
    defer journal.deinit();

    var view = try build(testing.allocator, &panel, &journal, false);
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 1), view.rows.len);
    try testing.expectEqualStrings("AAPL", view.rows[0].target);
    try testing.expectEqual(@as(usize, 1), view.total_active);
    try testing.expectEqual(@as(usize, 1), view.total_acked);
}

test "build: pass/skipped/err checks contribute no rows" {
    // Build a panel with a pass result by hand.
    const check_singleton = struct {
        const c: observations.Check = .{
            .name = "pass_check",
            .label = "Pass Check",
            // SAFETY: test-only Check; the panel is built with a
            // pre-baked `.complete = .pass` result so `runChecks`
            // never dispatches through this pointer.
            .run = undefined,
        };
    };
    const pending = try testing.allocator.alloc(observations.PendingCheck, 1);
    pending[0] = .{
        .check = &check_singleton.c,
        .state = .{ .complete = .pass },
    };
    var panel = CheckPanel{ .allocator = testing.allocator, .io = std.testing.io, .pending = pending };
    defer panel.deinit();

    var journal = try makeEmptyJournal(testing.allocator);
    defer journal.deinit();

    var view = try build(testing.allocator, &panel, &journal, false);
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 0), view.rows.len);
    try testing.expectEqual(@as(usize, 0), view.total_active);
    try testing.expectEqual(@as(usize, 0), view.total_acked);
}
