//! Git subprocess helpers.
//!
//! All functions shell out to the `git` binary. They are deliberately thin
//! wrappers — they don't try to reimplement git's object model, just
//! exec git with the right flags and classify common failure modes.
//!
//! Functions here exist primarily for commands that diff or walk a
//! repo-tracked portfolio file:
//!   - `zfin contributions` (HEAD~1 → HEAD or HEAD → working copy)
//!   - planned: `zfin snapshot` retroactive-fixup scan, which needs the
//!     last-modified time of the portfolio file from git
//!   - planned: `zfin contributions --timeline` walking a commit range
//!
//! If you need to add another git-shelling command, put it here rather
//! than inlining `std.process.Child.run` in the command module.

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

// ── Types ────────────────────────────────────────────────────

pub const Error = error{
    /// `git` binary not found on PATH, or failed to exec.
    GitUnavailable,
    /// The requested path is not in a git repo.
    NotInGitRepo,
    /// `git status` returned non-zero for the path.
    GitStatusFailed,
    /// `git show` returned non-zero for reasons other than the classified
    /// cases below (UnknownRevision, PathMissingInRev).
    GitShowFailed,
    /// The requested revision does not exist (e.g. HEAD~1 on a
    /// freshly-initialized repo with only one commit).
    UnknownRevision,
    /// The path is not present in the requested revision (e.g. asking for
    /// portfolio.srf at a commit that predates its addition).
    PathMissingInRev,
    /// `git log` returned non-zero.
    GitLogFailed,
    /// `resolveCommitRange` was asked for a `since` date with no commit
    /// at or before it — nothing to diff against.
    NoCommitAtOrBefore,
    /// The caller passed an invalid argument combination — e.g.
    /// `CommitSpec.working_copy` on the "before" side, which is
    /// nonsensical.
    InvalidArg,
    OutOfMemory,
};

/// Location of a path within a git repo.
pub const RepoInfo = struct {
    /// Absolute path to the repo root.
    root: []const u8,
    /// Relative path from root to the file (forward-slash separators).
    rel_path: []const u8,
};

/// A user-facing commit endpoint. Captures the three input shapes
/// accepted by `--commit-before` / `--commit-after` (and the
/// date-oriented `--since` / `--until` / compare positional args,
/// which the command layer parses into this type).
///
///   - `git_ref` — a string `git show <ref>:<path>` will accept
///     directly (SHA, HEAD, HEAD~N). Validation deferred to git.
///   - `date_at_or_before` — a calendar date. Resolved at
///     `resolveCommitRange` time via `commitAtOrBeforeDate`. Kept as
///     a date (not pre-resolved to a SHA) so the snap-note warning
///     can compare the resolved commit's timestamp against the
///     originally-requested date at report time.
///   - `working_copy` — the filesystem state (possibly dirty).
///     Valid only as the "after" endpoint — nonsensical as a
///     "before" because diffing the working copy against itself
///     produces nothing.
///
/// Defined here (not in `commands/common.zig`) because the concept
/// is git-layer. `commands/common.zig` has the user-input parser
/// that produces values of this type.
pub const CommitSpec = union(enum) {
    git_ref: []const u8,
    date_at_or_before: Date,
    working_copy,
};

/// Working-tree state of a single tracked path.
pub const PathStatus = enum {
    /// Tracked and has staged and/or unstaged changes.
    modified,
    /// Tracked and matches HEAD.
    clean,
    /// Not yet added to the repo.
    untracked,
};

/// Wall-clock time a commit touched a given path.
pub const CommitTouch = struct {
    commit: []const u8, // commit hash (40 chars for SHA-1, 64 for SHA-256)
    /// Committer timestamp (Unix epoch seconds).
    timestamp: i64,
};

/// A before/after pair of git revisions to diff.
///
/// `before_rev` is always a concrete revision (SHA or symbolic like
/// `HEAD~1`). `after_rev` is null when the caller wants the working
/// copy on the right-hand side; the caller reads the file directly
/// rather than going through `git show`.
pub const CommitRange = struct {
    before_rev: []const u8,
    /// null = working copy; non-null = a concrete git revision.
    after_rev: ?[]const u8,
};

// ── Implementation ───────────────────────────────────────────

/// Build a child-process environment from `base` with every `GIT_*`
/// variable removed.
///
/// zfin always targets a specific repo explicitly via `git -C <root>`,
/// so it must never honor an ambient `GIT_DIR` / `GIT_WORK_TREE` /
/// `GIT_INDEX_FILE`. Those are set whenever zfin (or its test suite)
/// runs inside a git hook (pre-commit, prek, ...), and `git -C` changes
/// the CWD but does NOT clear those env vars — git would silently
/// operate on the hook's repo instead of the portfolio's, reading the
/// wrong file (or none). See https://github.com/j178/prek/issues/1786
/// and https://pre-commit.com/ for the upstream guidance: code shelled
/// out by hooks that runs git against a different repo must explicitly
/// opt out of the inherited env.
///
/// Caller owns the returned map; free with `.deinit()`.
fn scrubbedEnv(
    allocator: std.mem.Allocator,
    base: *const std.process.Environ.Map,
) std.mem.Allocator.Error!std.process.Environ.Map {
    var map = try base.clone(allocator);
    errdefer map.deinit();

    // Collect-then-remove: the underlying ArrayHashMap can't be mutated
    // mid-iteration, and `swapRemove` frees the map's owned key buffer,
    // so the keys we want to remove must be duped first (otherwise the
    // pointers in `keys_to_remove` would dangle).
    var keys_to_remove: std.ArrayList([]u8) = .empty;
    defer {
        for (keys_to_remove.items) |k| allocator.free(k);
        keys_to_remove.deinit(allocator);
    }

    var it = map.iterator();
    while (it.next()) |entry| {
        if (std.mem.startsWith(u8, entry.key_ptr.*, "GIT_")) {
            try keys_to_remove.append(allocator, try allocator.dupe(u8, entry.key_ptr.*));
        }
    }
    for (keys_to_remove.items) |key| _ = map.swapRemove(key);

    return map;
}

/// Run `git` with the ambient `GIT_*` environment variables stripped
/// (see `scrubbedEnv`). Thin wrapper over `std.process.run` — callers
/// keep their own result/term handling. `env` is the process
/// environment to derive the child env from (e.g. `ctx.environ_map`).
fn runGit(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    argv: []const []const u8,
    stdout_limit: std.Io.Limit,
) !std.process.RunResult {
    var scrubbed = try scrubbedEnv(allocator, env);
    defer scrubbed.deinit();
    return std.process.run(allocator, io, .{
        .argv = argv,
        .environ_map = &scrubbed,
        .stdout_limit = stdout_limit,
    });
}

/// Locate the git repository containing `path` and the path's position
/// relative to the repo root.
///
/// Allocator is used for the returned `root` and `rel_path` strings
/// (caller-owned).
pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, env: *const std.process.Environ.Map, path: []const u8) Error!RepoInfo {
    // Resolve the file's directory. realpath requires the file to exist.
    const abs_path = std.Io.Dir.cwd().realPathFileAlloc(io, path, allocator) catch {
        return error.NotInGitRepo;
    };
    defer allocator.free(abs_path);
    const dir = std.fs.path.dirname(abs_path) orelse "/";

    // `git -C <dir> rev-parse --show-toplevel` prints the repo root.
    const result = runGit(io, allocator, env, &.{ "git", "-C", dir, "rev-parse", "--show-toplevel" }, .limited(64 * 1024)) catch {
        return error.GitUnavailable;
    };
    defer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .exited => |code| if (code != 0) return error.NotInGitRepo,
        else => return error.NotInGitRepo,
    }

    const root_raw = std.mem.trim(u8, result.stdout, " \t\r\n");
    const root = try allocator.dupe(u8, root_raw);
    errdefer allocator.free(root);

    // Relative path from root to the file. If `abs_path` starts with the
    // repo root (the common case), trim the prefix; otherwise fall back to
    // just the basename (extremely unusual — repo root disagrees with
    // path).
    const rel_raw = if (std.mem.startsWith(u8, abs_path, root) and abs_path.len > root.len)
        std.mem.trimStart(u8, abs_path[root.len..], "/")
    else
        std.fs.path.basename(abs_path);
    const rel = try allocator.dupe(u8, rel_raw);

    return .{ .root = root, .rel_path = rel };
}

/// Report the tracked/untracked/modified status of `rel_path` relative to
/// the repo at `root`.
pub fn pathStatus(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    root: []const u8,
    rel_path: []const u8,
) Error!PathStatus {
    const result = runGit(io, allocator, env, &.{ "git", "-C", root, "status", "--porcelain", "--", rel_path }, .limited(64 * 1024)) catch return error.GitUnavailable;
    defer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .exited => |code| if (code != 0) return error.GitStatusFailed,
        else => return error.GitStatusFailed,
    }

    const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");
    if (trimmed.len == 0) return .clean;
    // Porcelain format: "XY <path>". "??" is untracked, anything else is
    // modified (staged and/or unstaged).
    if (trimmed.len >= 2 and trimmed[0] == '?' and trimmed[1] == '?') return .untracked;
    return .modified;
}

/// Return the contents of `rel_path` at revision `rev` (as `git show
/// <rev>:<rel_path>` would print). Caller owns the returned bytes.
///
/// Classifies the common error cases so callers can print a useful
/// message: `UnknownRevision` (e.g. HEAD~1 on a fresh repo),
/// `PathMissingInRev` (file didn't exist at that commit).
pub fn show(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    root: []const u8,
    rev: []const u8,
    rel_path: []const u8,
) Error![]const u8 {
    const spec = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ rev, rel_path });
    defer allocator.free(spec);

    const result = runGit(io, allocator, env, &.{ "git", "-C", root, "show", spec }, .limited(32 * 1024 * 1024)) catch return error.GitUnavailable;
    errdefer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .exited => |code| {
            if (code != 0) {
                allocator.free(result.stdout);
                // Distinguish "no such revision" from "path missing".
                if (std.mem.indexOf(u8, result.stderr, "unknown revision") != null or
                    std.mem.indexOf(u8, result.stderr, "bad revision") != null or
                    std.mem.indexOf(u8, result.stderr, "ambiguous argument") != null)
                {
                    return error.UnknownRevision;
                }
                if (std.mem.indexOf(u8, result.stderr, "does not exist") != null or
                    std.mem.indexOf(u8, result.stderr, "exists on disk, but not in") != null)
                {
                    return error.PathMissingInRev;
                }
                return error.GitShowFailed;
            }
        },
        else => {
            allocator.free(result.stdout);
            return error.GitShowFailed;
        },
    }

    return result.stdout;
}

/// List commits that touched `rel_path`, newest-first, each with its
/// committer timestamp.
///
/// `since_iso` is passed verbatim to `git log --since=<...>`. If null, no
/// since filter is applied (returns the full history of the path). Valid
/// formats include ISO-8601 dates ("2026-01-01") and relative expressions
/// ("1 week ago").
///
/// Returned slice (and each `commit` string within) is caller-owned. Free
/// with `freeCommitTouches`.
pub fn listCommitsTouching(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    root: []const u8,
    rel_path: []const u8,
    since_iso: ?[]const u8,
) Error![]CommitTouch {
    var argv: std.ArrayList([]const u8) = .empty;
    defer argv.deinit(allocator);

    // Track the allocated `--since=...` string so we can free it regardless
    // of which index it ends up at in `argv`. (Don't rely on positional
    // arithmetic — it's brittle and freeing a string literal like "--"
    // would segfault on the debug allocator's memset-to-undefined.)
    var since_owned: ?[]u8 = null;
    defer if (since_owned) |s| allocator.free(s);

    try argv.appendSlice(allocator, &.{
        "git", "-C",              root,
        "log", "--format=%H %ct",
    });
    if (since_iso) |s| {
        const since_arg = try std.fmt.allocPrint(allocator, "--since={s}", .{s});
        since_owned = since_arg;
        try argv.append(allocator, since_arg);
    }
    try argv.appendSlice(allocator, &.{ "--", rel_path });

    const result = runGit(io, allocator, env, argv.items, .limited(16 * 1024 * 1024)) catch return error.GitUnavailable;
    defer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .exited => |code| if (code != 0) return error.GitLogFailed,
        else => return error.GitLogFailed,
    }

    var list: std.ArrayList(CommitTouch) = .empty;
    errdefer {
        for (list.items) |ct| allocator.free(ct.commit);
        list.deinit(allocator);
    }

    var line_iter = std.mem.splitScalar(u8, result.stdout, '\n');
    while (line_iter.next()) |line| {
        const trimmed = std.mem.trim(u8, line, " \t\r");
        if (trimmed.len == 0) continue;
        const sp = std.mem.indexOfScalar(u8, trimmed, ' ') orelse continue;
        const sha = trimmed[0..sp];
        const ts_str = trimmed[sp + 1 ..];
        const ts = std.fmt.parseInt(i64, ts_str, 10) catch continue;
        const sha_owned = try allocator.dupe(u8, sha);
        errdefer allocator.free(sha_owned);
        try list.append(allocator, .{ .commit = sha_owned, .timestamp = ts });
    }

    return list.toOwnedSlice(allocator);
}

/// Free a `CommitTouch` slice returned by `listCommitsTouching`.
pub fn freeCommitTouches(allocator: std.mem.Allocator, items: []const CommitTouch) void {
    for (items) |ct| allocator.free(ct.commit);
    allocator.free(items);
}

/// Return the committer timestamp (Unix epoch seconds) of the most recent
/// commit that touched `rel_path`, or null if the path has no history yet.
///
/// Equivalent to `git log -1 --format=%ct -- <rel_path>`.
pub fn lastCommitTimestampForPath(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    root: []const u8,
    rel_path: []const u8,
) Error!?i64 {
    const result = runGit(io, allocator, env, &.{ "git", "-C", root, "log", "-1", "--format=%ct", "--", rel_path }, .limited(64 * 1024)) catch return error.GitUnavailable;
    defer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .exited => |code| if (code != 0) return error.GitLogFailed,
        else => return error.GitLogFailed,
    }

    const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");
    if (trimmed.len == 0) return null;
    return std.fmt.parseInt(i64, trimmed, 10) catch return null;
}

/// Return the SHA of the most recent commit that touched `rel_path` at
/// or before `date_iso` (YYYY-MM-DD, inclusive end-of-day semantics via
/// `git log --until`).
///
/// Returns null if no commit before `date_iso` touched `rel_path`.
/// Caller owns the returned string.
///
/// Used by `zfin contributions --since <DATE>` / `--until <DATE>` to
/// resolve a date to the last commit that stamped a given snapshot of
/// the portfolio file.
pub fn commitAtOrBeforeDate(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    root: []const u8,
    rel_path: []const u8,
    date_iso: []const u8,
) Error!?[]const u8 {
    // `git log --until=DATE` with a bare YYYY-MM-DD uses the *current
    // time-of-day* applied to DATE as the cutoff — NOT end of day as
    // intuition suggests. That means at 10:40am today, `--until=X`
    // excludes any commits on X made after 10:40am, which causes
    // day-of-review windows to randomly include or exclude commits
    // depending on when the command is run. Explicitly pin the cutoff
    // to 23:59:59 local so "since 2026-04-25" always means "include
    // all commits on 2026-04-25, regardless of what time I'm
    // looking".
    const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
    defer allocator.free(until_arg);

    const result = runGit(io, allocator, env, &.{
        "git",     "-C", root,
        "log",     "-1", "--format=%H",
        until_arg, "--", rel_path,
    }, .limited(64 * 1024)) catch return error.GitUnavailable;
    defer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .exited => |code| if (code != 0) return error.GitLogFailed,
        else => return error.GitLogFailed,
    }

    const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");
    if (trimmed.len == 0) return null;
    // Defensive: `git log --format=%H` emits the full commit hash and
    // nothing else. Guard against stdout noise (e.g. a warning
    // accidentally routed to stdout) by requiring the result to look
    // like a hash — all hex, sensible length. SHA-1 is 40 chars,
    // SHA-256 is 64; accept anything in that range or longer to stay
    // forward-compatible with future git hash formats.
    if (trimmed.len < 40) return error.GitLogFailed;
    for (trimmed) |c| if (!std.ascii.isHex(c)) return error.GitLogFailed;
    return try allocator.dupe(u8, trimmed);
}

/// Repo-wide variant of `commitAtOrBeforeDate`. Returns the SHA of
/// the latest commit on HEAD whose committer-date is ≤ `date_iso`
/// 23:59:59 local, regardless of which paths the commit touched.
/// Returns null when no such commit exists.
///
/// Used by `zfin snapshot --as-of` to pick a single repo-wide
/// "as of date" reference; each portfolio file in the multi-file
/// glob is then read at that one SHA via `git show`. This produces
/// a coherent point-in-time snapshot — files that didn't exist at
/// that SHA (`error.PathMissingInRev`) are treated as absent
/// rather than as errors.
pub fn shaAtOrBefore(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    root: []const u8,
    date_iso: []const u8,
) Error!?[]const u8 {
    // Same end-of-day pinning as `commitAtOrBeforeDate` (see that
    // function's comment). No `-- <path>` filter — we want the
    // repo-wide latest.
    const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
    defer allocator.free(until_arg);

    const result = runGit(io, allocator, env, &.{
        "git",  "-C",      root,
        "log",  "-1",      "--format=%H",
        "HEAD", until_arg,
    }, .limited(64 * 1024)) catch return error.GitUnavailable;
    defer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .exited => |code| if (code != 0) return error.GitLogFailed,
        else => return error.GitLogFailed,
    }

    const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");
    if (trimmed.len == 0) return null;
    if (trimmed.len < 40) return error.GitLogFailed;
    for (trimmed) |c| if (!std.ascii.isHex(c)) return error.GitLogFailed;
    return try allocator.dupe(u8, trimmed);
}

/// Get the committer-date (Unix timestamp, seconds since epoch) of
/// a given ref. Accepts anything `git log -1 --format=%ct <ref>`
/// accepts: SHAs, HEAD, HEAD~N. Used by the contributions pipeline
/// to emit a snap-note when a date-form spec resolves to a commit
/// that's far from the user's requested date.
pub fn commitTimestamp(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    root: []const u8,
    ref: []const u8,
) Error!i64 {
    const result = runGit(io, allocator, env, &.{
        "git", "-C", root,
        "log", "-1", "--format=%ct",
        ref,
    }, .limited(4 * 1024)) catch return error.GitUnavailable;
    defer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .exited => |code| if (code != 0) return error.GitLogFailed,
        else => return error.GitLogFailed,
    }

    const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");
    if (trimmed.len == 0) return error.GitLogFailed;
    return std.fmt.parseInt(i64, trimmed, 10) catch return error.GitLogFailed;
}

/// Resolve a before/after commit range for diffing `repo.rel_path`.
///
/// Three modes selected by `since` / `until`:
///
///   - `since == null` (legacy): no date window.
///     - `dirty == false`: before=`HEAD~1`, after=`HEAD` (review last commit).
///     - `dirty == true`:  before=`HEAD`, after=working-copy.
///   - `since != null, until == null`: single cutoff.
///     - before = commit-at-or-before(since).
///     - `dirty == false`: after=`HEAD`.
///     - `dirty == true`:  after=working-copy.
///   - `since != null, until != null`: date window between two commits.
///     - before = commit-at-or-before(since).
///     - after  = commit-at-or-before(until).
///
/// `until` without `since` is rejected via assertion — the window is
/// ambiguous without a starting point. The caller is responsible for
/// enforcing that at the argument-parsing layer.
///
/// Returns `error.NoCommitAtOrBefore` when `since` or `until` resolves
/// to "no commit exists at or before this date". Callers decide how
/// to surface that to the user.
///
/// Pure SHA-level output — no labels, no stderr side effects. All
/// allocations use `arena`.
/// Resolve a before/after commit range for diffing `repo.rel_path`.
///
/// Takes `CommitSpec` for each endpoint. When `before` is null,
/// applies the legacy default (HEAD~1 clean / HEAD dirty). When
/// `after` is null, applies the legacy default for the after side
/// based on `dirty`. When explicitly provided, the specs are
/// honored as given.
///
/// Returns `error.NoCommitAtOrBefore` when a `date_at_or_before`
/// spec resolves to "no commit at or before this date." Returns
/// `error.InvalidArg` when `before` is `.working_copy` (nonsensical).
///
/// Pure SHA-level output — no labels, no stderr side effects. All
/// allocations use `arena`.
///
/// Three-tier rule for clarity:
///
///   1. Both specs explicit → honor as given.
///   2. One null, one explicit → fill the null from legacy defaults,
///      keeping the explicit side untouched.
///   3. Both null → full legacy mode: HEAD~1..HEAD (clean) or
///      HEAD..working-copy (dirty). Back-compat with pre-flag
///      `zfin contributions` invocations.
pub fn resolveCommitRangeSpec(
    io: std.Io,
    arena: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    repo: RepoInfo,
    before: ?CommitSpec,
    after: ?CommitSpec,
    dirty: bool,
) Error!CommitRange {
    // Before can't be working_copy — would be diffing against itself.
    if (before) |b| {
        if (b == .working_copy) return error.InvalidArg;
    }

    // Resolve each endpoint independently.
    const before_rev: []const u8 = if (before) |b|
        try resolveSpec(io, arena, env, repo, b)
    else if (dirty)
        "HEAD"
    else
        "HEAD~1";

    const after_rev: ?[]const u8 = if (after) |a|
        (switch (a) {
            .working_copy => null,
            else => try resolveSpec(io, arena, env, repo, a),
        })
    else if (dirty)
        null
    else
        "HEAD";

    return .{ .before_rev = before_rev, .after_rev = after_rev };
}

/// Resolve one non-working `CommitSpec` to a string git can consume.
/// Caller handles the `.working_copy` case separately (it's not a
/// git ref).
fn resolveSpec(io: std.Io, arena: std.mem.Allocator, env: *const std.process.Environ.Map, repo: RepoInfo, spec: CommitSpec) Error![]const u8 {
    return switch (spec) {
        .git_ref => |r| r,
        .date_at_or_before => |d| blk: {
            var buf: [10]u8 = undefined;
            // SAFETY: 10-byte buffer is exactly the size of "YYYY-MM-DD".
            const date_str = std.fmt.bufPrint(&buf, "{f}", .{d}) catch buf[0..];
            const sha = (try commitAtOrBeforeDate(io, arena, env, repo.root, repo.rel_path, date_str)) orelse
                return error.NoCommitAtOrBefore;
            break :blk sha;
        },
        .working_copy => error.InvalidArg,
    };
}

/// Back-compat wrapper for the original `Date`-based API. Existing
/// callers (legacy `zfin contributions --since / --until`) keep
/// working unchanged. New callers using explicit commit refs go
/// through `resolveCommitRangeSpec`.
///
/// `until` without `since` is rejected via assertion — the window is
/// ambiguous without a starting point.
pub fn resolveCommitRange(
    io: std.Io,
    arena: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    repo: RepoInfo,
    since: ?Date,
    until: ?Date,
    dirty: bool,
) Error!CommitRange {
    std.debug.assert(!(since == null and until != null));
    const before: ?CommitSpec = if (since) |d| .{ .date_at_or_before = d } else null;
    const after: ?CommitSpec = if (until) |d| .{ .date_at_or_before = d } else null;
    return resolveCommitRangeSpec(io, arena, env, repo, before, after, dirty);
}

// ── Tests ────────────────────────────────────────────────────
//
// These tests shell out to `git init` and friends in a tmp dir. They
// require `git` to be on PATH, which every developer setup already has
// (otherwise the `contributions` command would be unusable anyway).

/// Build a process-environment map for tests from the test runner's
/// captured environment.
///
/// The map is deliberately NOT pre-scrubbed: the git helpers strip
/// `GIT_*` internally (see `scrubbedEnv`), so passing the raw map here
/// exercises that scrubbing — which is exactly what lets these tests
/// pass when run under a git hook (pre-commit/prek), where `GIT_DIR`
/// etc. point at the outer repo. Caller owns the map; free with
/// `.deinit()`.
fn gitTestEnv(allocator: std.mem.Allocator) std.process.Environ.Map {
    return std.testing.environ.createMap(allocator) catch @panic("OOM building test env map");
}

test "findRepo locates the ambient zfin checkout" {
    // The test binary runs with cwd set to the project root, so this
    // should always succeed in CI and local dev. If git isn't available
    // we get NotInGitRepo or GitUnavailable — tolerate both (the test
    // environment is responsible for providing git).
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    // Pick any file that exists in the repo — build.zig is stable.
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);
    try std.testing.expect(info.root.len > 0);
    try std.testing.expectEqualStrings("build.zig", info.rel_path);
}

test "listCommitsTouching returns at least one commit for build.zig" {
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);
    const commits = listCommitsTouching(std.testing.io, allocator, &env, info.root, info.rel_path, null) catch return;
    defer freeCommitTouches(allocator, commits);
    try std.testing.expect(commits.len >= 1);
    // Timestamps are plausible (after 2020).
    try std.testing.expect(commits[0].timestamp > 1_577_836_800);
}

test "listCommitsTouching with non-null since_iso does not segfault" {
    // Regression: previously freed argv.items[6] which was the "--"
    // string literal when since_iso was non-null, segfaulting on the
    // debug allocator's memset-to-undefined.
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);
    // The test is primarily about not segfaulting; we don't assert on
    // commits.len since git's --since parsing may decline values that
    // are too far back (e.g. "100 years ago" can hit pre-epoch dates).
    const commits = listCommitsTouching(std.testing.io, allocator, &env, info.root, info.rel_path, "30 years ago") catch return;
    defer freeCommitTouches(allocator, commits);
}

test "commitAtOrBeforeDate returns a SHA for a past date" {
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);

    // Any date well after the repo's creation — commitAtOrBeforeDate
    // should find the most recent commit touching build.zig.
    const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return;
    try std.testing.expect(sha_opt != null);
    const sha = sha_opt.?;
    defer allocator.free(sha);
    // Accept either SHA-1 (40) or SHA-256 (64) format. Git is
    // gradually rolling out SHA-256; this test mustn't assume one.
    try std.testing.expect(sha.len == 40 or sha.len == 64);
    for (sha) |c| try std.testing.expect(std.ascii.isHex(c));
}

test "commitAtOrBeforeDate returns null for date before repo existed" {
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);

    // Pre-git — before any sensible project history.
    const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "1970-01-02") catch return;
    try std.testing.expect(sha_opt == null);
}

test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-day" {
    // Regression test for a subtle git UX trap. `git log --until=DATE`
    // with a bare YYYY-MM-DD applies the *current wall-clock
    // time-of-day* to DATE, rather than treating DATE as end-of-day.
    // So at 10:40am, `--until=2026-04-25` would exclude a commit
    // timestamped 2026-04-25 11:13am even though it's clearly within
    // 2026-04-25. We work around this by pinning the cutoff to
    // 23:59:59 in `commitAtOrBeforeDate`.
    //
    // This test pins the fix by querying a date far in the past and
    // confirming we get a commit (i.e. the helper doesn't over-
    // exclude). A more targeted test would need controllable
    // committer timestamps, which requires spinning up a tmp repo.
    // The behavior is validated end-to-end by compare + contributions
    // agreeing on `--since 1W` totals (see src/commands/contributions.zig
    // tests).
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);

    // Future-dated cutoff — should always return the tip of history
    // regardless of current wall-clock time.
    const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, &env, info.root, info.rel_path, "2099-01-01") catch return;
    try std.testing.expect(sha_opt != null);
    if (sha_opt) |s| allocator.free(s);
}

test "shaAtOrBefore returns a SHA for a past date in the ambient repo" {
    // Repo-wide variant of `commitAtOrBeforeDate` — finds the latest
    // commit on HEAD ≤ the given date, regardless of paths touched.
    // Same future-date trick as the path-scoped variant: a date well
    // beyond now should always return the tip of history.
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);

    const sha_opt = shaAtOrBefore(std.testing.io, allocator, &env, info.root, "2099-01-01") catch return;
    try std.testing.expect(sha_opt != null);
    const sha = sha_opt.?;
    defer allocator.free(sha);
    try std.testing.expect(sha.len == 40 or sha.len == 64);
    for (sha) |c| try std.testing.expect(std.ascii.isHex(c));
}

test "shaAtOrBefore returns null for date before repo existed" {
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);

    const sha_opt = shaAtOrBefore(std.testing.io, allocator, &env, info.root, "1970-01-02") catch return;
    try std.testing.expect(sha_opt == null);
}

test "resolveCommitRange: legacy clean → HEAD~1..HEAD" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    var env = gitTestEnv(std.testing.allocator);
    defer env.deinit();
    const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };

    const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), &env, repo, null, null, false);
    try std.testing.expectEqualStrings("HEAD~1", range.before_rev);
    try std.testing.expectEqualStrings("HEAD", range.after_rev.?);
}

test "resolveCommitRange: legacy dirty → HEAD..working-copy" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    var env = gitTestEnv(std.testing.allocator);
    defer env.deinit();
    const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" };

    const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), &env, repo, null, null, true);
    try std.testing.expectEqualStrings("HEAD", range.before_rev);
    try std.testing.expect(range.after_rev == null);
}

test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" {
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);

    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();

    // Any date well after project start — resolves to latest commit.
    const range = resolveCommitRange(
        std.testing.io,
        arena_state.allocator(),
        &env,
        info,
        Date.fromYmd(2099, 1, 1),
        null,
        false,
    ) catch return;
    try std.testing.expect(range.before_rev.len >= 40);
    try std.testing.expectEqualStrings("HEAD", range.after_rev.?);
}

test "resolveCommitRange: --since with no earlier commit → NoCommitAtOrBefore" {
    const allocator = std.testing.allocator;
    var env = gitTestEnv(allocator);
    defer env.deinit();
    const info = findRepo(std.testing.io, allocator, &env, "build.zig") catch return;
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);

    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();

    // Before any commit exists in this repo.
    const result = resolveCommitRange(
        std.testing.io,
        arena_state.allocator(),
        &env,
        info,
        Date.fromYmd(1970, 1, 2),
        null,
        false,
    );
    try std.testing.expectError(error.NoCommitAtOrBefore, result);
}
