const std = @import("std");
const crypto = @import("crypto.zig");
const json = @import("json.zig");

/// Cache entry for a single file
const CacheEntry = struct {
    mtime: i64,
    hash: []const u8,

    pub fn deinit(self: *const CacheEntry, allocator: std.mem.Allocator) void {
        allocator.free(self.hash);
    }
};

/// Hash cache that stores file mtimes and hashes to avoid re-hashing unchanged files
pub const HashCache = struct {
    entries: std.StringHashMap(CacheEntry),
    allocator: std.mem.Allocator,
    cache_path: []const u8,
    dirty: bool,

    pub fn init(allocator: std.mem.Allocator) HashCache {
        return .{
            .entries = std.StringHashMap(CacheEntry).init(allocator),
            .allocator = allocator,
            .cache_path = "",
            .dirty = false,
        };
    }

    pub fn deinit(self: *HashCache) void {
        var it = self.entries.iterator();
        while (it.next()) |entry| {
            entry.value_ptr.deinit(self.allocator);
            self.allocator.free(entry.key_ptr.*);
        }
        self.entries.deinit();
        if (self.cache_path.len > 0) {
            self.allocator.free(self.cache_path);
        }
    }

    /// Get default cache path: ~/.ml/cache/hashes.json
    pub fn getDefaultPath(allocator: std.mem.Allocator) ![]const u8 {
        const home = std.posix.getenv("HOME") orelse {
            return error.NoHomeDirectory;
        };

        // Ensure cache directory exists
        const cache_dir = try std.fs.path.join(allocator, &[_][]const u8{ home, ".ml", "cache" });
        defer allocator.free(cache_dir);

        std.fs.cwd().makeDir(cache_dir) catch |err| switch (err) {
            error.PathAlreadyExists => {},
            else => return err,
        };

        return try std.fs.path.join(allocator, &[_][]const u8{ home, ".ml", "cache", "hashes.json" });
    }

    /// Load cache from disk
    pub fn load(self: *HashCache) !void {
        const cache_path = try getDefaultPath(self.allocator);
        self.cache_path = cache_path;

        const file = std.fs.cwd().openFile(cache_path, .{}) catch |err| switch (err) {
            error.FileNotFound => return, // No cache yet is fine
            else => return err,
        };
        defer file.close();

        const content = try file.readToEndAlloc(self.allocator, 10 * 1024 * 1024); // Max 10MB
        defer self.allocator.free(content);

        // Parse JSON
        const parsed = try std.json.parseFromSlice(std.json.Value, self.allocator, content, .{});
        defer parsed.deinit();

        const root = parsed.value.object;
        const version = root.get("version") orelse return error.InvalidCacheFormat;
        if (version.integer != 1) return error.UnsupportedCacheVersion;

        const files = root.get("files") orelse return error.InvalidCacheFormat;
        if (files.object.count() == 0) return;

        var it = files.object.iterator();
        while (it.next()) |entry| {
            const path = try self.allocator.dupe(u8, entry.key_ptr.*);

            const file_obj = entry.value_ptr.object;
            const mtime = file_obj.get("mtime") orelse continue;
            const hash_val = file_obj.get("hash") orelse continue;

            const hash = try self.allocator.dupe(u8, hash_val.string);

            try self.entries.put(path, .{
                .mtime = mtime.integer,
                .hash = hash,
            });
        }
    }

    /// Save cache to disk
    pub fn save(self: *HashCache) !void {
        if (!self.dirty) return;

        var json_str = std.ArrayList(u8).init(self.allocator);
        defer json_str.deinit();

        var writer = json_str.writer();

        // Write header
        try writer.print("{{\n  \"version\": 1,\n  \"files\": {{\n", .{});

        // Write entries
        var it = self.entries.iterator();
        var first = true;
        while (it.next()) |entry| {
            if (!first) try writer.print(",\n", .{});
            first = false;

            // Escape path for JSON
            const escaped_path = try json.escapeString(self.allocator, entry.key_ptr.*);
            defer self.allocator.free(escaped_path);

            try writer.print("    \"{s}\": {{\"mtime\": {d}, \"hash\": \"{s}\"}}", .{
                escaped_path,
                entry.value_ptr.mtime,
                entry.value_ptr.hash,
            });
        }

        // Write footer
        try writer.print("\n  }}\n}}\n", .{});

        // Write atomically
        const tmp_path = try std.fmt.allocPrint(self.allocator, "{s}.tmp", .{self.cache_path});
        defer self.allocator.free(tmp_path);

        {
            const file = try std.fs.cwd().createFile(tmp_path, .{});
            defer file.close();
            try file.writeAll(json_str.items);
        }

        try std.fs.cwd().rename(tmp_path, self.cache_path);
        self.dirty = false;
    }

    /// Check if file needs re-hashing
    pub fn needsHash(self: *HashCache, path: []const u8, mtime: i64) bool {
        const entry = self.entries.get(path) orelse return true;
        return entry.mtime != mtime;
    }

    /// Get cached hash for file
    pub fn getHash(self: *HashCache, path: []const u8, mtime: i64) ?[]const u8 {
        const entry = self.entries.get(path) orelse return null;
        if (entry.mtime != mtime) return null;
        return entry.hash;
    }

    /// Store hash for file
    pub fn putHash(self: *HashCache, path: []const u8, mtime: i64, hash: []const u8) !void {
        const path_copy = try self.allocator.dupe(u8, path);

        // Remove old entry if exists
        if (self.entries.fetchRemove(path_copy)) |old| {
            self.allocator.free(old.key);
            old.value.deinit(self.allocator);
        }

        const hash_copy = try self.allocator.dupe(u8, hash);

        try self.entries.put(path_copy, .{
            .mtime = mtime,
            .hash = hash_copy,
        });

        self.dirty = true;
    }

    /// Clear cache (e.g., after git checkout)
    pub fn clear(self: *HashCache) void {
        var it = self.entries.iterator();
        while (it.next()) |entry| {
            entry.value_ptr.deinit(self.allocator);
            self.allocator.free(entry.key_ptr.*);
        }
        self.entries.clearRetainingCapacity();
        self.dirty = true;
    }

    /// Get cache stats
    pub fn getStats(self: *HashCache) struct { entries: usize, dirty: bool } {
        return .{
            .entries = self.entries.count(),
            .dirty = self.dirty,
        };
    }
};

/// Calculate directory hash with cache support
pub fn hashDirectoryWithCache(
    allocator: std.mem.Allocator,
    dir_path: []const u8,
    cache: *HashCache,
) ![]const u8 {
    var hasher = std.crypto.hash.sha2.Sha256.init(.{});

    var dir = try std.fs.cwd().openDir(dir_path, .{ .iterate = true });
    defer dir.close();

    // Load .gitignore patterns
    var gitignore = @import("ignore.zig").GitIgnore.init(allocator);
    defer gitignore.deinit();

    try gitignore.loadFromDir(dir_path, ".gitignore");
    try gitignore.loadFromDir(dir_path, ".mlignore");

    var walker = try dir.walk(allocator);
    defer walker.deinit(allocator);

    // Collect paths and check cache
    var paths: std.ArrayList(struct { path: []const u8, mtime: i64, use_cache: bool }) = .{};
    defer {
        for (paths.items) |p| allocator.free(p.path);
        paths.deinit(allocator);
    }

    while (try walker.next()) |entry| {
        if (entry.kind == .file) {
            // Skip files matching default ignores
            if (@import("ignore.zig").matchesDefaultIgnore(entry.path)) continue;

            // Skip files matching .gitignore/.mlignore patterns
            if (gitignore.isIgnored(entry.path, false)) continue;

            const full_path = try std.fs.path.join(allocator, &[_][]const u8{ dir_path, entry.path });
            defer allocator.free(full_path);

            const stat = dir.statFile(entry.path) catch |err| switch (err) {
                error.FileNotFound => continue,
                else => return err,
            };

            const mtime = @as(i64, @intCast(stat.mtime));
            const use_cache = !cache.needsHash(entry.path, mtime);

            try paths.append(.{
                .path = try allocator.dupe(u8, entry.path),
                .mtime = mtime,
                .use_cache = use_cache,
            });
        }
    }

    // Sort paths for deterministic hashing
    std.sort.block(
        struct { path: []const u8, mtime: i64, use_cache: bool },
        paths.items,
        {},
        struct {
            fn lessThan(_: void, a: anytype, b: anytype) bool {
                return std.mem.order(u8, a.path, b.path) == .lt;
            }
        }.lessThan,
    );

    // Hash each file (using cache where possible)
    for (paths.items) |item| {
        hasher.update(item.path);
        hasher.update(&[_]u8{0}); // Separator

        const file_hash: []const u8 = if (item.use_cache)
            cache.getHash(item.path, item.mtime).?
        else blk: {
            const full_path = try std.fs.path.join(allocator, &[_][]const u8{ dir_path, item.path });
            defer allocator.free(full_path);

            const hash = try crypto.hashFile(allocator, full_path);
            try cache.putHash(item.path, item.mtime, hash);
            break :blk hash;
        };
        defer if (!item.use_cache) allocator.free(file_hash);

        hasher.update(file_hash);
        hasher.update(&[_]u8{0}); // Separator
    }

    var hash: [32]u8 = undefined;
    hasher.final(&hash);
    return crypto.encodeHexLower(allocator, &hash);
}

test "HashCache basic operations" {
    const allocator = std.testing.allocator;

    var cache = HashCache.init(allocator);
    defer cache.deinit();

    // Put and get
    try cache.putHash("src/main.py", 1708369200, "abc123");

    const hash = cache.getHash("src/main.py", 1708369200);
    try std.testing.expect(hash != null);
    try std.testing.expectEqualStrings("abc123", hash.?);

    // Wrong mtime should return null
    const stale = cache.getHash("src/main.py", 1708369201);
    try std.testing.expect(stale == null);

    // needsHash should detect stale entries
    try std.testing.expect(cache.needsHash("src/main.py", 1708369201));
    try std.testing.expect(!cache.needsHash("src/main.py", 1708369200));
}

test "HashCache clear" {
    const allocator = std.testing.allocator;

    var cache = HashCache.init(allocator);
    defer cache.deinit();

    try cache.putHash("file1.py", 123, "hash1");
    try cache.putHash("file2.py", 456, "hash2");

    try std.testing.expectEqual(@as(usize, 2), cache.getStats().entries);

    cache.clear();

    try std.testing.expectEqual(@as(usize, 0), cache.getStats().entries);
    try std.testing.expect(cache.getStats().dirty);
}
