//! 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 filename for the portfolio file when no explicit -p/--portfolio
/// is provided. Looked up via `resolveUserFile` (cwd → ZFIN_HOME). 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,
alphavantage_key: ?[]const u8 = null,
tiingo_key: ?[]const u8 = null,
openfigi_key: ?[]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(){
        .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.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY");
    self.tiingo_key = self.resolve("TIINGO_API_KEY");
    self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
    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, trying cwd first then ZFIN_HOME.
/// 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 (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| {
        return .{ .path = rel_path, .owned = false };
    } else |_| {}

    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);
        }
    }
    return null;
}

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

pub fn hasAnyKey(self: @This()) bool {
    return self.twelvedata_key != null or
        self.polygon_key != null or
        self.fmp_key != null or
        self.alphavantage_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.
    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, alphavantage };
    for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .fmp, .alphavantage }) |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",
            .alphavantage => c.alphavantage_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);
}
