//! The closed enums a `query_log` row stores to explain one query: what the
//! policy decided, why, and where the answer came from.
//!
//! They live in a module of their own because everything that touches a logged
//! row needs them — `storage/logger.zig`, `storage/repositories/queries_repo.zig`,
//! `server/handler.zig` and the web serializers — and `logger` already imports
//! `queries_repo`, so enums owned by either would close a loop.
//!
//! Each value is stored as its `@tagName` and read back through `parse`. The
//! read path treats an unrecognised value as a data error rather than passing
//! the text through: the column is a closed set, and a row that disagrees came
//! from something other than this schema.

const std = @import("std");

const matcher = @import("../filter/matcher.zig");

/// Whether the filtering policy reached a verdict on this query, and which one.
///
/// `not_evaluated` is the honest answer for a query the pipeline answered before
/// filtering could apply — a non-IN question, a paused resolver, a protocol
/// refusal — and is not the same as "allowed".
pub const PolicyAction = enum {
    not_evaluated,
    allow,
    block,
};

/// Why the policy landed where it did.
///
/// The first nine are `filter/matcher.zig`'s serializable reasons, one for one.
/// The rest name the pipeline steps that decide a query without consulting the
/// matcher at all.
pub const PolicyReason = enum {
    rule_allow_exact,
    rule_block_exact,
    rule_allow_wildcard,
    rule_block_wildcard,
    rule_allow_regex,
    rule_block_regex,
    blocklist_exception,
    blocklist_domain,
    blocklist_wildcard,

    /// Answered from `local_records`, before filtering.
    local_record,
    /// Answered by a configured forward zone, before filtering.
    forward_zone,
    /// The question was not class IN, so no rule could apply to it.
    non_in_class,
    /// Filtering was paused.
    paused,
    /// No filter snapshot was published yet, so the query went unfiltered.
    snapshot_unavailable,
    /// The matcher evaluated the name and nothing matched.
    no_match,
    /// A syntactically parsed request refused on protocol grounds — BADVERS,
    /// NOTIMP, a malformed EDNS OPT. It names a question, so it is logged, but
    /// no policy ever saw it.
    protocol_error,
};

/// Where the answer the client received came from.
pub const RouteKind = enum {
    blocked,
    local,
    forward_zone,
    upstream,
    cache,
    rejected,
};

/// The matcher's verdict in the query log's vocabulary.
///
/// Exhaustive on purpose: a reason added to the matcher must be given a stored
/// name here rather than silently reaching a row as something else. `.none` is
/// the matcher's "nothing matched", which is exactly `no_match`.
pub fn fromMatcherReason(reason: matcher.Reason) PolicyReason {
    return switch (reason) {
        .none => .no_match,
        .rule_allow_exact => .rule_allow_exact,
        .rule_block_exact => .rule_block_exact,
        .rule_allow_wildcard => .rule_allow_wildcard,
        .rule_block_wildcard => .rule_block_wildcard,
        .rule_allow_regex => .rule_allow_regex,
        .rule_block_regex => .rule_block_regex,
        .blocklist_exception => .blocklist_exception,
        .blocklist_domain => .blocklist_domain,
        .blocklist_wildcard => .blocklist_wildcard,
    };
}

/// Reads a stored `@tagName` back. `error.Mismatch` is the same error the
/// repositories return for a column that does not hold what the schema says it
/// holds, which is what an unknown value here is.
pub fn parse(comptime Enum: type, text: []const u8) error{Mismatch}!Enum {
    return std.meta.stringToEnum(Enum, text) orelse error.Mismatch;
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const testing = std.testing;

test "every matcher reason has a stored name" {
    // The mapping is checked here rather than trusted: a reason added to the
    // matcher fails the exhaustive switch at compile time, and a reason
    // *renamed* would still compile while changing what a row says.
    inline for (@typeInfo(matcher.Reason).@"enum".fields) |field| {
        const reason: matcher.Reason = @enumFromInt(field.value);
        const mapped = fromMatcherReason(reason);
        if (reason == .none) {
            try testing.expectEqual(PolicyReason.no_match, mapped);
        } else {
            try testing.expectEqualStrings(field.name, @tagName(mapped));
        }
    }
}

test "parse round-trips every value of every enum" {
    inline for ([_]type{ PolicyAction, PolicyReason, RouteKind }) |Enum| {
        inline for (@typeInfo(Enum).@"enum".fields) |field| {
            const value: Enum = @enumFromInt(field.value);
            try testing.expectEqual(value, try parse(Enum, @tagName(value)));
        }
    }
}

test "parse rejects a value the schema does not define" {
    try testing.expectError(error.Mismatch, parse(PolicyAction, "allowed"));
    try testing.expectError(error.Mismatch, parse(PolicyAction, ""));
    // A value that belongs to a different one of the three enums is no more
    // acceptable than a typo.
    try testing.expectError(error.Mismatch, parse(RouteKind, "allow"));
    try testing.expectError(error.Mismatch, parse(PolicyReason, "cache"));
}
