const std = @import("std");
const builtin = @import("builtin");
const std_compat = @import("compat.zig");

pub const home_env_var = "NULLBOILER_HOME";
pub const home_dir_name = ".nullboiler";

pub const WorkerConfig = struct {
    id: []const u8,
    url: []const u8,
    token: []const u8,
    protocol: []const u8 = "webhook",
    model: ?[]const u8 = null,
    tags: []const []const u8 = &.{},
    max_concurrent: u32 = 1,
};

pub const EngineConfig = struct {
    poll_interval_ms: u32 = 500,
    default_timeout_ms: u32 = 300000,
    default_max_attempts: u32 = 1,
    health_check_interval_ms: u32 = 30000,
    worker_failure_threshold: u32 = 3,
    worker_circuit_breaker_ms: u32 = 60000,
    retry_base_delay_ms: u32 = 1000,
    retry_max_delay_ms: u32 = 30000,
    retry_jitter_ms: u32 = 250,
    retry_max_elapsed_ms: u32 = 900000,
    shutdown_grace_ms: u32 = 30000,
};

pub const ConcurrencyConfig = struct {
    max_concurrent_tasks: u32 = 10,
    per_pipeline: ?std.json.Value = null,
    per_role: ?std.json.Value = null,
    per_state: ?std.json.Value = null,
};

pub const WorkspaceHooksConfig = struct {
    after_create: ?[]const u8 = null,
    before_run: ?[]const u8 = null,
    after_run: ?[]const u8 = null,
    before_remove: ?[]const u8 = null,
};

pub const WorkspaceConfig = struct {
    root: []const u8 = "/tmp/nullboiler-workspaces",
    hooks: WorkspaceHooksConfig = .{},
    hook_timeout_ms: u32 = 30000,
};

pub const SubprocessDefaults = struct {
    command: []const u8 = "nullclaw",
    args: []const []const u8 = &.{},
    base_port: u16 = 9200,
    health_check_retries: u32 = 10,
    max_turns: u32 = 20,
    turn_timeout_ms: u32 = 600000,
    continuation_prompt: []const u8 = "Continue working on this task. Your previous context is preserved.",
};

pub const TrackerConfig = struct {
    url: ?[]const u8 = null,
    api_token: ?[]const u8 = null,
    poll_interval_ms: u32 = 10000,
    agent_id: []const u8 = "nullboiler",
    concurrency: ConcurrencyConfig = .{},
    workspace: WorkspaceConfig = .{},
    subprocess: SubprocessDefaults = .{},
    stall_timeout_ms: u32 = 300000,
    lease_ttl_ms: u32 = 60000,
    heartbeat_interval_ms: u32 = 30000,
    workflows_dir: []const u8 = "workflows",
};

pub const Config = struct {
    host: []const u8 = "127.0.0.1",
    port: u16 = 8080,
    db: []const u8 = "nullboiler.db",
    api_token: ?[]const u8 = null,
    self_url: ?[]const u8 = null,
    strategies_dir: []const u8 = "strategies",
    workers: []const WorkerConfig = &.{},
    engine: EngineConfig = .{},
    tracker: ?TrackerConfig = null,
};

pub fn resolveConfigPath(allocator: std.mem.Allocator, override_path: ?[]const u8) ![]const u8 {
    if (override_path) |path| return allocator.dupe(u8, path);

    const home_dir = try resolveHomeDir(allocator);
    defer allocator.free(home_dir);
    return std.fs.path.join(allocator, &.{ home_dir, "config.json" });
}

pub fn resolveHomeDir(allocator: std.mem.Allocator) ![]const u8 {
    if (std_compat.process.getEnvVarOwned(allocator, home_env_var)) |env_home| {
        return env_home;
    } else |err| switch (err) {
        error.EnvironmentVariableNotFound => {},
        else => return err,
    }

    const home = try getHomeDirOwned(allocator);
    defer allocator.free(home);
    return std.fs.path.join(allocator, &.{ home, home_dir_name });
}

/// Load configuration from a JSON file. If the file does not exist,
/// return a default Config.
pub fn loadFromFile(allocator: std.mem.Allocator, path: []const u8) !Config {
    const file = std_compat.fs.cwd().openFile(path, .{}) catch |err| {
        if (err == error.FileNotFound) {
            return Config{};
        }
        return err;
    };
    defer file.close();

    const contents = try file.readToEndAlloc(allocator, 1024 * 1024);

    // Do NOT free `contents` or deinit `parsed` here.
    // `Config` fields may point into these allocations.
    // The caller should provide an arena allocator and clean it up once on shutdown.
    const parsed = try std.json.parseFromSlice(Config, allocator, contents, .{ .ignore_unknown_fields = true });

    return parsed.value;
}

pub fn resolveRelativePaths(allocator: std.mem.Allocator, config_path: []const u8, cfg: *Config) !void {
    cfg.db = try resolveRelativePath(allocator, config_path, cfg.db);
    cfg.strategies_dir = try resolveRelativePath(allocator, config_path, cfg.strategies_dir);

    if (cfg.tracker) |*tracker| {
        tracker.workflows_dir = try resolveRelativePath(allocator, config_path, tracker.workflows_dir);
        tracker.workspace.root = try resolveRelativePath(allocator, config_path, tracker.workspace.root);
    }
}

fn resolveRelativePath(allocator: std.mem.Allocator, config_path: []const u8, value: []const u8) ![]const u8 {
    if (value.len == 0 or std.fs.path.isAbsolute(value)) return value;

    const base_dir = std.fs.path.dirname(config_path) orelse ".";
    return std.fs.path.resolve(allocator, &.{ base_dir, value });
}

fn getHomeDirOwned(allocator: std.mem.Allocator) ![]u8 {
    return std_compat.process.getEnvVarOwned(allocator, "HOME") catch |err| switch (err) {
        error.EnvironmentVariableNotFound => {
            if (builtin.os.tag == .windows) {
                return std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch error.HomeNotSet;
            }
            return error.HomeNotSet;
        },
        else => return err,
    };
}

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

test "default config" {
    const cfg = Config{};
    try std.testing.expectEqualStrings("127.0.0.1", cfg.host);
    try std.testing.expectEqual(@as(u16, 8080), cfg.port);
    try std.testing.expectEqualStrings("nullboiler.db", cfg.db);
    try std.testing.expectEqual(@as(?[]const u8, null), cfg.api_token);
    try std.testing.expectEqual(@as(usize, 0), cfg.workers.len);
}

test "loadFromFile returns default when file not found" {
    const allocator = std.testing.allocator;
    const cfg = try loadFromFile(allocator, "nonexistent_config_file_12345.json");
    try std.testing.expectEqualStrings("127.0.0.1", cfg.host);
    try std.testing.expectEqual(@as(u16, 8080), cfg.port);
    try std.testing.expectEqualStrings("nullboiler.db", cfg.db);
    try std.testing.expectEqual(@as(?[]const u8, null), cfg.api_token);
}

test "loadFromFile reads configured host and worker URL from JSON file" {
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const cfg_json =
        \\{
        \\  "host": "127.0.0.1",
        \\  "port": 8099,
        \\  "db": "cfg.db",
        \\  "tracker": {
        \\    "url": "http://127.0.0.1:7700",
        \\    "agent_id": "boiler-1",
        \\    "concurrency": {
        \\      "max_concurrent_tasks": 3
        \\    },
        \\    "workflows_dir": "workflows"
        \\  },
        \\  "workers": [
        \\    {
        \\      "id": "w1",
        \\      "url": "http://localhost:3000/webhook",
        \\      "token": "tok",
        \\      "protocol": "webhook",
        \\      "tags": ["coder"],
        \\      "max_concurrent": 1
        \\    }
        \\  ]
        \\}
    ;

    try std_compat.fs.Dir.wrap(tmp.dir).writeFile(.{
        .sub_path = "config.json",
        .data = cfg_json,
    });

    const cfg_path = try std_compat.fs.Dir.wrap(tmp.dir).realpathAlloc(std.testing.allocator, "config.json");
    defer std.testing.allocator.free(cfg_path);

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

    const cfg = try loadFromFile(arena.allocator(), cfg_path);
    try std.testing.expectEqualStrings("127.0.0.1", cfg.host);
    try std.testing.expectEqual(@as(u16, 8099), cfg.port);
    try std.testing.expectEqualStrings("cfg.db", cfg.db);
    try std.testing.expectEqual(@as(usize, 1), cfg.workers.len);
    try std.testing.expectEqualStrings("http://localhost:3000/webhook", cfg.workers[0].url);
    try std.testing.expect(cfg.tracker != null);
    try std.testing.expectEqualStrings("http://127.0.0.1:7700", cfg.tracker.?.url.?);
    try std.testing.expectEqualStrings("boiler-1", cfg.tracker.?.agent_id);
    try std.testing.expectEqual(@as(u32, 3), cfg.tracker.?.concurrency.max_concurrent_tasks);
    try std.testing.expectEqualStrings("workflows", cfg.tracker.?.workflows_dir);
}

test "loadFromFile ignores unknown fields at all config levels" {
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const cfg_json =
        \\{
        \\  "host": "0.0.0.0",
        \\  "future_top_level": true,
        \\  "workers": [
        \\    {
        \\      "id": "w1",
        \\      "url": "http://localhost:3000/webhook",
        \\      "token": "tok",
        \\      "future_worker": "ignored"
        \\    }
        \\  ],
        \\  "engine": {
        \\    "poll_interval_ms": 250,
        \\    "future_engine": 42
        \\  },
        \\  "tracker": {
        \\    "url": "http://127.0.0.1:7700",
        \\    "future_tracker": true,
        \\    "concurrency": {
        \\      "max_concurrent_tasks": 2,
        \\      "future_concurrency": 9
        \\    },
        \\    "workspace": {
        \\      "root": "workspaces",
        \\      "future_workspace": "ignored",
        \\      "hooks": {
        \\        "before_run": "echo ok",
        \\        "future_hook": "ignored"
        \\      }
        \\    },
        \\    "subprocess": {
        \\      "command": "nullclaw",
        \\      "future_subprocess": "ignored"
        \\    }
        \\  }
        \\}
    ;

    try std_compat.fs.Dir.wrap(tmp.dir).writeFile(.{
        .sub_path = "config.json",
        .data = cfg_json,
    });

    const cfg_path = try std_compat.fs.Dir.wrap(tmp.dir).realpathAlloc(std.testing.allocator, "config.json");
    defer std.testing.allocator.free(cfg_path);

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

    const cfg = try loadFromFile(arena.allocator(), cfg_path);
    try std.testing.expectEqualStrings("0.0.0.0", cfg.host);
    try std.testing.expectEqual(@as(usize, 1), cfg.workers.len);
    try std.testing.expectEqualStrings("w1", cfg.workers[0].id);
    try std.testing.expectEqual(@as(u32, 250), cfg.engine.poll_interval_ms);
    try std.testing.expectEqualStrings("http://127.0.0.1:7700", cfg.tracker.?.url.?);
    try std.testing.expectEqual(@as(u32, 2), cfg.tracker.?.concurrency.max_concurrent_tasks);
    try std.testing.expectEqualStrings("workspaces", cfg.tracker.?.workspace.root);
    try std.testing.expectEqualStrings("echo ok", cfg.tracker.?.workspace.hooks.before_run.?);
    try std.testing.expectEqualStrings("nullclaw", cfg.tracker.?.subprocess.command);
}

test "TrackerConfig defaults" {
    const tc = TrackerConfig{};
    try std.testing.expectEqual(@as(?[]const u8, null), tc.url);
    try std.testing.expectEqual(@as(u32, 10000), tc.poll_interval_ms);
    try std.testing.expectEqual(@as(u32, 10), tc.concurrency.max_concurrent_tasks);
    try std.testing.expectEqual(@as(u32, 300000), tc.stall_timeout_ms);
    try std.testing.expectEqual(@as(u32, 60000), tc.lease_ttl_ms);
    try std.testing.expectEqual(@as(u32, 30000), tc.heartbeat_interval_ms);
    try std.testing.expectEqualStrings("workflows", tc.workflows_dir);
}

test "Config with tracker null by default" {
    const cfg = Config{};
    try std.testing.expectEqual(@as(?TrackerConfig, null), cfg.tracker);
}

test "SubprocessDefaults has base_port and health_check_retries" {
    const sd = SubprocessDefaults{};
    try std.testing.expectEqual(@as(u16, 9200), sd.base_port);
    try std.testing.expectEqual(@as(u32, 10), sd.health_check_retries);
    try std.testing.expectEqualStrings("nullclaw", sd.command);
    try std.testing.expectEqual(@as(usize, 0), sd.args.len);
    try std.testing.expectEqualStrings("Continue working on this task. Your previous context is preserved.", sd.continuation_prompt);
}

test "resolveRelativePaths anchors tracker paths to config directory" {
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try std_compat.fs.Dir.wrap(tmp.dir).makePath("configs");
    const cfg_json =
        \\{
        \\  "db": "data/nullboiler.db",
        \\  "strategies_dir": "strategies",
        \\  "tracker": {
        \\    "url": "http://127.0.0.1:7700",
        \\    "workflows_dir": "workflows",
        \\    "workspace": {
        \\      "root": "workspaces"
        \\    },
        \\    "concurrency": {
        \\      "max_concurrent_tasks": 4
        \\    }
        \\  }
        \\}
    ;

    try std_compat.fs.Dir.wrap(tmp.dir).writeFile(.{
        .sub_path = "configs/config.json",
        .data = cfg_json,
    });

    const cfg_path = try std_compat.fs.Dir.wrap(tmp.dir).realpathAlloc(std.testing.allocator, "configs/config.json");
    defer std.testing.allocator.free(cfg_path);

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

    var cfg = try loadFromFile(arena.allocator(), cfg_path);
    try resolveRelativePaths(arena.allocator(), cfg_path, &cfg);

    const tracker = cfg.tracker.?;
    const config_dir = std.fs.path.dirname(cfg_path).?;
    const expected_db = try std.fs.path.resolve(arena.allocator(), &.{ config_dir, "data/nullboiler.db" });
    const expected_strategies = try std.fs.path.resolve(arena.allocator(), &.{ config_dir, "strategies" });
    const expected_workflows = try std.fs.path.resolve(arena.allocator(), &.{ config_dir, "workflows" });
    const expected_workspace = try std.fs.path.resolve(arena.allocator(), &.{ config_dir, "workspaces" });

    try std.testing.expectEqualStrings(expected_db, cfg.db);
    try std.testing.expectEqualStrings(expected_strategies, cfg.strategies_dir);
    try std.testing.expectEqualStrings(expected_workflows, tracker.workflows_dir);
    try std.testing.expectEqualStrings(expected_workspace, tracker.workspace.root);
    try std.testing.expectEqual(@as(u32, 4), tracker.concurrency.max_concurrent_tasks);
}

test "parse concurrency config with per_state" {
    const allocator = std.testing.allocator;
    const json =
        \\{
        \\  "max_concurrent_tasks": 10,
        \\  "per_state": {"in_progress": 5, "rework": 2}
        \\}
    ;
    const parsed = try std.json.parseFromSlice(ConcurrencyConfig, allocator, json, .{ .ignore_unknown_fields = true });
    defer parsed.deinit();
    try std.testing.expect(parsed.value.per_state != null);
    try std.testing.expect(parsed.value.per_state.? == .object);
}
