//! Runtime configuration: API keys, cache directory, resolved data paths.
//!
//! File-as-struct: the file *is* the `Config` type. Callers reach it via
//! `const Config = @import("Config.zig");` and use it anywhere a struct
//! type is expected (`Config.fromEnv(alloc)`, `var c: Config = ...`,
//! `Config.ResolvedPath`, `Config.default_portfolio_filename`, etc.).
//!
//! Configuration sources, in priority order:
//!   1. Process environment variables
//!   2. `.env` file in the current working directory
//!   3. `.env` file in `$ZFIN_HOME`
//!
//! Cache directory defaults follow XDG: `$ZFIN_CACHE_DIR` > `$XDG_CACHE_HOME/zfin`
//! > `$HOME/.cache/zfin`.

const std = @import("std");

const EnvMap = std.StringHashMap([]const u8);

/// Default pattern for the portfolio file when no explicit -p/--portfolio
/// is provided. Looked up via `resolveUserFiles` (cwd → ZFIN_HOME). The
/// `*` is intentional — multiple files matching `portfolio*.srf` are all
/// loaded and union-merged. A user with just one `portfolio.srf` is
/// unaffected (the glob still matches that single file). Every command
/// that loads a portfolio should fall back to this so behavior stays
/// consistent.
pub const default_portfolio_filename = "portfolio*.srf";

/// Default filename for the watchlist file. Same resolution rules as
/// `default_portfolio_filename`.
pub const default_watchlist_filename = "watchlist.srf";

// ── Fields ───────────────────────────────────────────────────

twelvedata_key: ?[]const u8 = null,
polygon_key: ?[]const u8 = null,
fmp_key: ?[]const u8 = null,
tiingo_key: ?[]const u8 = null,
openfigi_key: ?[]const u8 = null,
/// User contact email used as the User-Agent / From header for
/// open-data providers that require politeness identification
/// (Wikidata SPARQL, EDGAR). No API-key authentication semantics —
/// just identifies the operator. Sourced from `ZFIN_USER_EMAIL`.
user_email: ?[]const u8 = null,
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
server_url: ?[]const u8 = null,
cache_dir: []const u8,
cache_dir_owned: bool = false, // true when cache_dir was allocated via path.join
zfin_home: ?[]const u8 = null,
allocator: ?std.mem.Allocator = null,
/// Raw .env file contents (keys/values in env_map point into this).
env_buf: ?[]const u8 = null,
/// Parsed KEY=VALUE pairs from .env file (fallback when process env is missing a key).
env_map: ?EnvMap = null,
/// Process-level environment variable map (from Juicy Main). First-priority
/// lookup source before falling back to the .env file.
environ_map: ?*const std.process.Environ.Map = null,

// ── Construction / teardown ──────────────────────────────────

pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std.process.Environ.Map) @This() {
    var self = @This(){
        // SAFETY: assigned unconditionally below (line ~95) from
        // either ZFIN_CACHE_DIR or the XDG fallback before this
        // function returns, so callers never observe `undefined`.
        .cache_dir = undefined,
        .allocator = allocator,
        .environ_map = environ_map,
    };

    // Try loading .env file from the current working directory
    self.env_buf = std.Io.Dir.cwd().readFileAlloc(io, ".env", allocator, .limited(4096)) catch null;
    if (self.env_buf) |buf| {
        self.env_map = parseEnvFile(allocator, buf);
    }

    self.zfin_home = self.resolve("ZFIN_HOME");

    // Try loading .env file from ZFIN_HOME as well (cwd .env takes priority)
    if (self.env_buf == null) {
        if (self.zfin_home) |home| {
            const env_path = std.fs.path.join(allocator, &.{ home, ".env" }) catch null;
            if (env_path) |p| {
                defer allocator.free(p);
                self.env_buf = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(4096)) catch null;
                if (self.env_buf) |buf| {
                    self.env_map = parseEnvFile(allocator, buf);
                }
            }
        }
    }

    self.twelvedata_key = self.resolve("TWELVEDATA_API_KEY");
    self.polygon_key = self.resolve("POLYGON_API_KEY");
    self.fmp_key = self.resolve("FMP_API_KEY");
    self.tiingo_key = self.resolve("TIINGO_API_KEY");
    self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
    self.user_email = self.resolve("ZFIN_USER_EMAIL");
    self.server_url = self.resolve("ZFIN_SERVER");

    const env_cache = self.resolve("ZFIN_CACHE_DIR");
    self.cache_dir = env_cache orelse blk: {
        self.cache_dir_owned = true;
        // XDG Base Directory: $XDG_CACHE_HOME/zfin, falling back to $HOME/.cache/zfin
        const xdg = self.resolve("XDG_CACHE_HOME");
        const base = xdg orelse fallback: {
            const home = self.resolve("HOME") orelse "/tmp";
            break :fallback std.fs.path.join(allocator, &.{ home, ".cache" }) catch @panic("OOM");
        };
        const base_allocated = xdg == null;
        defer if (base_allocated) allocator.free(base);
        break :blk std.fs.path.join(allocator, &.{ base, "zfin" }) catch @panic("OOM");
    };

    return self;
}

pub fn deinit(self: *@This()) void {
    if (self.allocator) |a| {
        if (self.env_map) |*m| {
            var map = m.*;
            map.deinit();
        }
        if (self.env_buf) |buf| a.free(buf);
        if (self.cache_dir_owned) {
            a.free(self.cache_dir);
        }
    }
}

// ── File resolution ──────────────────────────────────────────

pub const ResolvedPath = struct {
    path: []const u8,
    owned: bool,

    pub fn deinit(self: ResolvedPath, allocator: std.mem.Allocator) void {
        if (self.owned) allocator.free(self.path);
    }
};

/// Resolve a user file. ZFIN_HOME is exclusive when set: only
/// `$ZFIN_HOME/<rel_path>` is checked. When ZFIN_HOME is unset,
/// fall back to cwd-relative resolution.
///
/// The exclusivity is intentional: setting ZFIN_HOME is the
/// user's "this is where my data lives" declaration, and silently
/// falling back to cwd undermines that. Running from a project
/// directory that incidentally ships a `portfolio.srf` would
/// otherwise shadow the user's canonical data — exactly the
/// surprising behavior we want to rule out. If a user wants
/// cwd-based resolution for a one-off run, they can `unset
/// ZFIN_HOME` (or `env -u ZFIN_HOME zfin ...`).
///
/// Returns the path to use; caller must call `deinit()` on the result.
pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator, rel_path: []const u8) ?ResolvedPath {
    if (self.zfin_home) |home| {
        const full = std.fs.path.join(allocator, &.{ home, rel_path }) catch return null;
        if (std.Io.Dir.cwd().access(io, full, .{})) |_| {
            return .{ .path = full, .owned = true };
        } else |_| {
            allocator.free(full);
        }
        // ZFIN_HOME is set but doesn't have the file. Don't look
        // in cwd — that would be the surprising-shadow case.
        return null;
    }

    if (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| {
        return .{ .path = rel_path, .owned = false };
    } else |_| {}

    return null;
}

/// Returns true if `pattern` contains glob metacharacters (`*`, `?`).
/// Callers use this to decide between literal-name resolution
/// (`resolveUserFile`) and glob expansion (`resolveUserFiles`).
///
/// Brackets (`[...]`) are NOT treated as glob characters because
/// `globMatch` doesn't support them. If we claimed `[` was a glob
/// metachar, a pattern like `foo[a-z].srf` would route to the glob
/// path, fail to match anything (the matcher treats `[` as a literal),
/// and silently drop the pattern. Treating `[` as literal keeps
/// behavior predictable: a literal-with-`[` falls through to the
/// "literal file path" code path and gets a useful "Cannot read"
/// error if the file doesn't exist.
pub fn isGlobPattern(pattern: []const u8) bool {
    for (pattern) |c| {
        if (c == '*' or c == '?') return true;
    }
    return false;
}

/// Match a filename against a glob pattern. Supports `*` (any run of
/// chars, including empty) and `?` (exactly one char). Brackets and
/// `**` are not supported — `[` is matched as a literal character.
/// Match is anchored at both ends.
pub fn globMatch(pattern: []const u8, name: []const u8) bool {
    return globMatchInner(pattern, name);
}

fn globMatchInner(pattern: []const u8, name: []const u8) bool {
    var pi: usize = 0;
    var ni: usize = 0;
    // Backtrack state for `*`: `star_pi` is the index of the `*` in
    // the pattern; `match_ni` is the position in `name` we backtracked
    // to plus the chars matched so far.
    var star_pi: ?usize = null;
    var match_ni: usize = 0;

    while (ni < name.len) {
        if (pi < pattern.len) {
            const pc = pattern[pi];
            if (pc == '?') {
                pi += 1;
                ni += 1;
                continue;
            }
            if (pc == '*') {
                star_pi = pi;
                match_ni = ni;
                pi += 1;
                continue;
            }
            if (pc == name[ni]) {
                pi += 1;
                ni += 1;
                continue;
            }
        }
        // Mismatch (or pattern exhausted) — backtrack to last `*` if any.
        if (star_pi) |sp| {
            pi = sp + 1;
            match_ni += 1;
            ni = match_ni;
            continue;
        }
        return false;
    }
    // Eat trailing `*`s.
    while (pi < pattern.len and pattern[pi] == '*') : (pi += 1) {}
    return pi == pattern.len;
}

/// A list of resolved paths returned by `resolveUserFiles`. Holds the
/// allocator and a contiguous slice of `ResolvedPath`. Caller must
/// `deinit()` once finished.
pub const ResolvedPaths = struct {
    paths: []const ResolvedPath,
    allocator: std.mem.Allocator,

    pub fn deinit(self: ResolvedPaths) void {
        for (self.paths) |rp| rp.deinit(self.allocator);
        self.allocator.free(self.paths);
    }

    /// Convenience: return just the path strings as a slice. Slices
    /// point into the `paths[i].path` fields, so they live as long as
    /// the `ResolvedPaths` does.
    pub fn paths_slice(self: ResolvedPaths, allocator: std.mem.Allocator) ![]const []const u8 {
        const out = try allocator.alloc([]const u8, self.paths.len);
        for (self.paths, 0..) |rp, i| out[i] = rp.path;
        return out;
    }
};

/// Resolve a portfolio-like path that may contain glob metacharacters.
///
/// Resolution rules:
///   - When ZFIN_HOME is set, search EXCLUSIVELY there. cwd is not
///     consulted. Setting ZFIN_HOME is the user's declaration of
///     "this is where my data lives"; silently falling back to
///     cwd would let an incidental `portfolio.srf` in a project
///     directory shadow the user's real data.
///   - When ZFIN_HOME is unset, search cwd.
///   - Literal patterns (no glob metachar) → 0 or 1 path via
///     `resolveUserFile`. Glob patterns → expansion against the
///     selected directory.
///   - Returns an empty slice (not null) when the pattern has
///     no matches in the selected directory.
///
/// Caller must call `deinit()` on the returned `ResolvedPaths`.
pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, pattern: []const u8) !ResolvedPaths {
    if (!isGlobPattern(pattern)) {
        // Literal-path fast path: reuse the singular resolver,
        // which itself enforces the ZFIN_HOME-exclusive rule.
        if (self.resolveUserFile(io, allocator, pattern)) |r| {
            const arr = try allocator.alloc(ResolvedPath, 1);
            arr[0] = r;
            return .{ .paths = arr, .allocator = allocator };
        }
        return .{ .paths = &.{}, .allocator = allocator };
    }

    // Glob expansion. ZFIN_HOME exclusive when set.
    if (self.zfin_home) |home| {
        if (try expandGlob(io, allocator, home, pattern, .home_relative)) |matches| {
            return .{ .paths = matches, .allocator = allocator };
        }
        // ZFIN_HOME directory doesn't exist or can't be read.
        // Surface as no-match rather than fall back to cwd.
        return .{ .paths = &.{}, .allocator = allocator };
    }

    // ZFIN_HOME unset — cwd is the only option.
    if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
        return .{ .paths = matches, .allocator = allocator };
    }

    return .{ .paths = &.{}, .allocator = allocator };
}

/// Where the directory we're scanning lives. Determines whether
/// resulting paths are bare filenames (cwd) or fully joined
/// (ZFIN_HOME), which in turn drives `ResolvedPath.owned`.
const GlobDirKind = enum { cwd_relative, home_relative };

/// Expand `pattern` against the directory at `dir_path`. Returns a
/// sorted slice of `ResolvedPath`, or null if the directory can't be
/// opened. Empty slice (non-null) means "directory exists, zero matches".
fn expandGlob(
    io: std.Io,
    allocator: std.mem.Allocator,
    dir_path: []const u8,
    pattern: []const u8,
    kind: GlobDirKind,
) !?[]ResolvedPath {
    var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return null;
    defer dir.close(io);

    var matches: std.ArrayList(ResolvedPath) = .empty;
    errdefer {
        for (matches.items) |rp| rp.deinit(allocator);
        matches.deinit(allocator);
    }

    var it = dir.iterate();
    while (try it.next(io)) |entry| {
        if (entry.kind != .file) continue;
        if (!globMatch(pattern, entry.name)) continue;

        const rp: ResolvedPath = switch (kind) {
            .cwd_relative => .{
                .path = try allocator.dupe(u8, entry.name),
                .owned = true,
            },
            .home_relative => .{
                .path = try std.fs.path.join(allocator, &.{ dir_path, entry.name }),
                .owned = true,
            },
        };
        try matches.append(allocator, rp);
    }

    const out = try matches.toOwnedSlice(allocator);
    // Sort the resolved paths by their path string for determinism.
    const PathLess = struct {
        fn lt(_: void, a: ResolvedPath, b: ResolvedPath) bool {
            return std.mem.lessThan(u8, a.path, b.path);
        }
    };
    std.mem.sort(ResolvedPath, out, {}, PathLess.lt);
    return out;
}

// ── Queries ──────────────────────────────────────────────────

pub fn hasAnyKey(self: @This()) bool {
    return self.twelvedata_key != null or
        self.polygon_key != null or
        self.fmp_key != null or
        self.tiingo_key != null;
}

// ── Internals ────────────────────────────────────────────────

/// Look up a key: process environment first, then .env file fallback.
fn resolve(self: *@This(), key: []const u8) ?[]const u8 {
    if (self.environ_map) |em| {
        if (em.get(key)) |v| return v;
    }
    if (self.env_map) |m| return m.get(key);
    return null;
}

/// Parse all KEY=VALUE pairs from .env content into a HashMap.
/// Values are slices into the original buffer (no extra allocations per entry).
fn parseEnvFile(allocator: std.mem.Allocator, data: []const u8) ?EnvMap {
    var map = EnvMap.init(allocator);
    var iter = std.mem.splitScalar(u8, data, '\n');
    while (iter.next()) |line| {
        const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
        if (trimmed.len == 0 or trimmed[0] == '#') continue;
        if (std.mem.indexOfScalar(u8, trimmed, '=')) |eq| {
            const k = std.mem.trim(u8, trimmed[0..eq], &std.ascii.whitespace);
            const v = std.mem.trim(u8, trimmed[eq + 1 ..], &std.ascii.whitespace);
            map.put(k, v) catch return null;
        }
    }
    return map;
}

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

const testing = std.testing;

test "default_portfolio_filename / default_watchlist_filename are the expected literals" {
    // Guards against accidental rename; these constants are referenced by
    // every command that loads a portfolio or watchlist, and changing them
    // silently would break user setups. The portfolio default is a glob —
    // intentional, so users with multiple portfolio_*.srf files get them
    // all loaded by default.
    try testing.expectEqualStrings("portfolio*.srf", default_portfolio_filename);
    try testing.expectEqualStrings("watchlist.srf", default_watchlist_filename);
}

test "parseEnvFile: basic KEY=VALUE pairs" {
    const data = "FOO=bar\nBAZ=qux\n";
    var map = parseEnvFile(testing.allocator, data).?;
    defer map.deinit();
    try testing.expectEqualStrings("bar", map.get("FOO").?);
    try testing.expectEqualStrings("qux", map.get("BAZ").?);
    try testing.expect(map.get("MISSING") == null);
}

test "parseEnvFile: trims whitespace around key and value" {
    const data = "  SPACED_KEY  =  spaced value  \n";
    var map = parseEnvFile(testing.allocator, data).?;
    defer map.deinit();
    try testing.expectEqualStrings("spaced value", map.get("SPACED_KEY").?);
}

test "parseEnvFile: ignores comments and blank lines" {
    const data =
        \\# this is a comment
        \\
        \\FOO=bar
        \\# another comment
        \\
        \\BAZ=qux
        \\
    ;
    var map = parseEnvFile(testing.allocator, data).?;
    defer map.deinit();
    try testing.expectEqual(@as(usize, 2), map.count());
    try testing.expectEqualStrings("bar", map.get("FOO").?);
    try testing.expectEqualStrings("qux", map.get("BAZ").?);
}

test "parseEnvFile: lines without '=' are skipped" {
    const data =
        \\NOT_A_PAIR
        \\FOO=bar
        \\ALSO NOT A PAIR
        \\BAZ=qux
        \\
    ;
    var map = parseEnvFile(testing.allocator, data).?;
    defer map.deinit();
    try testing.expectEqual(@as(usize, 2), map.count());
    try testing.expectEqualStrings("bar", map.get("FOO").?);
    try testing.expectEqualStrings("qux", map.get("BAZ").?);
}

test "parseEnvFile: value containing '=' keeps everything after the first '='" {
    const data = "URL=https://example.com/path?q=1&r=2\n";
    var map = parseEnvFile(testing.allocator, data).?;
    defer map.deinit();
    try testing.expectEqualStrings("https://example.com/path?q=1&r=2", map.get("URL").?);
}

test "parseEnvFile: empty input yields empty map" {
    var map = parseEnvFile(testing.allocator, "").?;
    defer map.deinit();
    try testing.expectEqual(@as(usize, 0), map.count());
}

test "hasAnyKey: false when all keys are null" {
    const c: @This() = .{ .cache_dir = "/tmp" };
    try testing.expect(!c.hasAnyKey());
}

test "hasAnyKey: true when any single provider key is set" {
    // Each key should independently flip the result to true. Iterating
    // through each variant catches a future field addition that forgets
    // to update hasAnyKey().
    const KeyField = enum { tiingo, twelvedata, polygon, fmp };
    for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .fmp }) |which| {
        var c: @This() = .{ .cache_dir = "/tmp" };
        switch (which) {
            .tiingo => c.tiingo_key = "abc",
            .twelvedata => c.twelvedata_key = "abc",
            .polygon => c.polygon_key = "abc",
            .fmp => c.fmp_key = "abc",
        }
        try testing.expect(c.hasAnyKey());
    }
}

test "hasAnyKey: openfigi / server_url don't count as provider keys" {
    // hasAnyKey is about candle/quote providers; OpenFIGI is CUSIP lookup
    // and server_url is the sync server. Neither should flip hasAnyKey.
    var c: @This() = .{ .cache_dir = "/tmp" };
    c.openfigi_key = "fg";
    c.server_url = "https://example.com";
    try testing.expect(!c.hasAnyKey());
}

test "ResolvedPath.deinit: frees when owned, no-op when not owned" {
    const allocator = testing.allocator;

    // Not-owned: a static literal must NOT be freed. The testing allocator
    // would panic if we tried to free a non-allocation — success here is
    // the test returning normally.
    const rp_static: ResolvedPath = .{ .path = "portfolio.srf", .owned = false };
    rp_static.deinit(allocator);

    // Owned: allocator-allocated path is freed by deinit.
    const owned = try allocator.dupe(u8, "/tmp/portfolio.srf");
    const rp_owned: ResolvedPath = .{ .path = owned, .owned = true };
    rp_owned.deinit(allocator);
    // (If this leaked, the test allocator would fail the test.)
}

test "resolve: env_map fallback when environ_map is null (skips process env)" {
    // Setting environ_map=null disables the process-env branch, so the
    // lookup must come from env_map alone. This exercises the .env-only
    // code path without depending on host environment variables.
    var map = EnvMap.init(testing.allocator);
    defer map.deinit();
    try map.put("FOO", "from-env-file");

    var c: @This() = .{
        .cache_dir = "/tmp",
        .env_map = map,
        .allocator = null,
    };
    try testing.expectEqualStrings("from-env-file", c.resolve("FOO").?);
    try testing.expect(c.resolve("MISSING") == null);
}

test "resolve: environ_map=null and env_map=null returns null" {
    var c: @This() = .{ .cache_dir = "/tmp" };
    try testing.expect(c.resolve("ANYTHING") == null);
}

// ── Glob tests ────────────────────────────────────────────────

test "isGlobPattern: detects metacharacters" {
    try testing.expect(isGlobPattern("portfolio*.srf"));
    try testing.expect(isGlobPattern("portfolio_?.srf"));
    try testing.expect(!isGlobPattern("portfolio.srf"));
    try testing.expect(!isGlobPattern(""));
    try testing.expect(!isGlobPattern("foo/bar.srf"));
    // Brackets are NOT treated as a glob char — see doc-comment.
    // A literal filename containing `[` falls through to the
    // literal-path resolver, not the glob path.
    try testing.expect(!isGlobPattern("foo[abc].srf"));
}

test "globMatch: literal match" {
    try testing.expect(globMatch("portfolio.srf", "portfolio.srf"));
    try testing.expect(!globMatch("portfolio.srf", "portfolio_a.srf"));
    try testing.expect(!globMatch("portfolio.srf", "portfolio.SRF"));
}

test "globMatch: simple star" {
    try testing.expect(globMatch("portfolio*.srf", "portfolio.srf"));
    try testing.expect(globMatch("portfolio*.srf", "portfolio_main.srf"));
    try testing.expect(globMatch("portfolio*.srf", "portfolio_other.srf"));
    try testing.expect(!globMatch("portfolio*.srf", "watchlist.srf"));
    try testing.expect(!globMatch("portfolio*.srf", "portfolio.txt"));
}

test "globMatch: leading and trailing stars" {
    try testing.expect(globMatch("*.srf", "portfolio.srf"));
    try testing.expect(globMatch("*.srf", "a.srf"));
    try testing.expect(!globMatch("*.srf", "a.txt"));
    try testing.expect(globMatch("*", "anything"));
    try testing.expect(globMatch("*", ""));
    try testing.expect(globMatch("portfolio*", "portfolio"));
    try testing.expect(globMatch("portfolio*", "portfolio_x"));
}

test "globMatch: question mark matches exactly one char" {
    try testing.expect(globMatch("?.srf", "a.srf"));
    try testing.expect(!globMatch("?.srf", "ab.srf"));
    try testing.expect(!globMatch("?.srf", ".srf"));
    try testing.expect(globMatch("portfolio_?.srf", "portfolio_1.srf"));
    try testing.expect(!globMatch("portfolio_?.srf", "portfolio_12.srf"));
}

test "globMatch: multiple stars" {
    try testing.expect(globMatch("*foo*", "foo"));
    try testing.expect(globMatch("*foo*", "afoob"));
    try testing.expect(globMatch("*foo*", "abfoocd"));
    try testing.expect(!globMatch("*foo*", "fo"));
    try testing.expect(globMatch("a*b*c", "abc"));
    try testing.expect(globMatch("a*b*c", "axxbyyc"));
    try testing.expect(!globMatch("a*b*c", "abd"));
}

test "globMatch: edge cases" {
    try testing.expect(globMatch("", ""));
    try testing.expect(!globMatch("", "x"));
    try testing.expect(!globMatch("x", ""));
    try testing.expect(globMatch("**", ""));
    try testing.expect(globMatch("**", "anything"));
}

test "resolveUserFiles: literal name resolves to single path (or empty)" {
    const allocator = testing.allocator;
    const io = std.testing.io;

    // No env / no zfin_home — literal name not in cwd → empty result.
    const c: @This() = .{ .cache_dir = "/tmp" };
    var result = try c.resolveUserFiles(io, allocator, "definitely-does-not-exist-zfin.srf");
    defer result.deinit();
    try testing.expectEqual(@as(usize, 0), result.paths.len);
}

test "resolveUserFiles: glob pattern, no matches" {
    const allocator = testing.allocator;
    const io = std.testing.io;

    // Use a pattern guaranteed not to match anything in cwd.
    const c: @This() = .{ .cache_dir = "/tmp" };
    var result = try c.resolveUserFiles(io, allocator, "zfin-nope-*.srf-xyz");
    defer result.deinit();
    try testing.expectEqual(@as(usize, 0), result.paths.len);
}

test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
    const allocator = testing.allocator;
    const io = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Use a pattern unlikely to match anything in the project's
    // cwd. With ZFIN_HOME → cwd priority, ZFIN_HOME would win
    // anyway when both have matches, but the test is cleanest
    // if cwd contributes nothing (the zfintest_pf prefix won't
    // collide with the project's portfolio*.srf files in the
    // repo root).
    try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf.srf", .data = "x" });
    try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_b.srf", .data = "x" });
    try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_a.srf", .data = "x" });
    try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });

    var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
    const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
    defer allocator.free(dir_path);

    const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
    var result = try c.resolveUserFiles(io, allocator, "zfintest_pf*.srf");
    defer result.deinit();
    try testing.expectEqual(@as(usize, 3), result.paths.len);
    // Sorted: zfintest_pf.srf, zfintest_pf_a.srf, zfintest_pf_b.srf
    try testing.expect(std.mem.endsWith(u8, result.paths[0].path, "zfintest_pf.srf"));
    try testing.expect(std.mem.endsWith(u8, result.paths[1].path, "zfintest_pf_a.srf"));
    try testing.expect(std.mem.endsWith(u8, result.paths[2].path, "zfintest_pf_b.srf"));
}

test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)" {
    // Pin the rule: when ZFIN_HOME is set, we ONLY look there.
    // cwd is not a fallback. The motivating bug: a project
    // directory that incidentally ships a `portfolio.srf` would
    // shadow the user's canonical data when running zfin from
    // that directory. ZFIN_HOME-exclusive rules that out.
    //
    // Verified by giving the resolver a ZFIN_HOME that doesn't
    // match a pattern, then confirming the result is empty —
    // even though the test runner's cwd (the repo root) DOES
    // have a portfolio*.srf file.
    const allocator = testing.allocator;
    const io = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // ZFIN_HOME has no portfolio*.srf — only an unrelated file.
    try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });

    var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
    const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
    defer allocator.free(dir_path);

    const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
    var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf");
    defer result.deinit();
    // Zero matches in ZFIN_HOME → zero results, full stop.
    // cwd is NOT consulted, even though the test runner's cwd
    // (the repo root) typically has a `portfolio-semilatest.srf`.
    try testing.expectEqual(@as(usize, 0), result.paths.len);
}

test "resolveUserFiles: cwd used only when ZFIN_HOME is unset" {
    // Counter-test for the exclusivity rule: with ZFIN_HOME
    // unset, cwd IS consulted. Exercise this via expandGlob
    // directly (testing the cwd code path means mutating the
    // process cwd, which is risky in a parallel runner). The
    // resolveUserFiles wrapper just routes to one of these
    // two modes based on whether `zfin_home` is non-null.
    const allocator = testing.allocator;
    const io = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" });
    try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" });
    try tmp.dir.writeFile(io, .{ .sub_path = "other.txt", .data = "x" });

    var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
    const dir_path = dir_path_buf[0..dir_path_len];

    // Glob expansion against a known directory (mimicking the
    // cwd path of resolveUserFiles).
    const matches_opt = try expandGlob(io, allocator, dir_path, "portfolio_*.srf", .home_relative);
    try testing.expect(matches_opt != null);
    const matches = matches_opt.?;
    defer {
        for (matches) |rp| rp.deinit(allocator);
        allocator.free(matches);
    }
    try testing.expectEqual(@as(usize, 2), matches.len);
    try testing.expect(std.mem.endsWith(u8, matches[0].path, "portfolio_a.srf"));
    try testing.expect(std.mem.endsWith(u8, matches[1].path, "portfolio_b.srf"));
}

test "resolveUserFile: ZFIN_HOME is exclusive when set (literal path)" {
    // Same exclusivity rule, but for the no-glob path through
    // `resolveUserFile`. ZFIN_HOME without the file → null,
    // even when the file might exist in cwd.
    const allocator = testing.allocator;
    const io = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    // ZFIN_HOME is empty (no portfolio.srf inside).
    var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
    const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
    defer allocator.free(dir_path);

    const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
    // The repo root (test cwd) has `portfolio-semilatest.srf`,
    // but we ask for a different name to keep the test
    // deterministic regardless of cwd contents.
    const result = c.resolveUserFile(io, allocator, "portfolio-semilatest.srf");
    // ZFIN_HOME doesn't have it; cwd is not consulted; null.
    try testing.expect(result == null);
}

test "expandGlob: missing directory returns null" {
    const allocator = testing.allocator;
    const io = std.testing.io;
    const result = try expandGlob(io, allocator, "/zfin-test-no-such-dir-xyz", "*.srf", .home_relative);
    try testing.expect(result == null);
}
