//! Hub Artifact endpoints. The Artifact is the org's rule collection and single source of truth.
//! Serves rule metadata, batched rule content, and bundle definitions.
const std = @import("std");
const httpz = @import("httpz");
const artifact_api = @import("clumsies_lib").protocol.artifact_api;
const Server = @import("server.zig");
const auth = @import("auth.zig");
const apiError = @import("api_error.zig").send;
const BundleListResponse = artifact_api.BundleListResponse;
const BundleMeta = artifact_api.BundleMeta;
const RuleListResponse = artifact_api.RuleListResponse;
const RuleMeta = artifact_api.RuleMeta;
const BatchRuleContentRequest = artifact_api.BatchRuleContentRequest;
const BatchRuleContentResponse = artifact_api.BatchRuleContentResponse;
const BatchRuleItem = artifact_api.BatchRuleItem;

const BATCH_MAX_IDS: usize = 1024;

pub fn handleListRules(ctx: *Server.Context, req: *httpz.Request, res: *httpz.Response) !void {
    const user = auth.authenticate(ctx, req) catch {
        return apiError(res, 401, "UNAUTHORIZED", "invalid or missing token");
    };
    if (!auth.requireScope(user, "artifact:read", res)) return;

    const qs = req.query() catch {
        return apiError(res, 400, "BAD_REQUEST", "invalid query string");
    };
    const path_prefix = qs.get("path_prefix");

    const conn = ctx.pool.acquire() catch {
        return apiError(res, 503, "SERVICE_UNAVAILABLE", "database unavailable");
    };
    defer conn.release();

    var result = if (path_prefix) |prefix| blk: {
        const like_arg = try std.fmt.allocPrint(req.arena, "{s}%", .{prefix});
        break :blk conn.query(
            "SELECT p.rule_id, p.path, p.content_hash, p.updated_at::text, o.name as source FROM rules p JOIN orgs o ON o.org_id = p.org_id WHERE p.org_id = $1::uuid AND p.path LIKE $2 ORDER BY p.path",
            .{ user.org_id, like_arg },
        ) catch {
            return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
        };
    } else conn.query(
        "SELECT p.rule_id, p.path, p.content_hash, p.updated_at::text, o.name as source FROM rules p JOIN orgs o ON o.org_id = p.org_id WHERE p.org_id = $1::uuid ORDER BY p.path",
        .{user.org_id},
    ) catch {
        return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
    };
    defer result.deinit();

    var list: std.ArrayList(RuleMeta) = .empty;
    while (try result.next()) |row| {
        try list.append(req.arena, .{
            .rule_id = try req.arena.dupe(u8, try row.get([]const u8, 0)),
            .path = try req.arena.dupe(u8, try row.get([]const u8, 1)),
            .content_hash = try req.arena.dupe(u8, try row.get([]const u8, 2)),
            .updated_at = try req.arena.dupe(u8, try row.get([]const u8, 3)),
            .source = try req.arena.dupe(u8, try row.get([]const u8, 4)),
        });
    }

    try res.json(RuleListResponse{ .rules = list.items }, .{});
}

/// Batch rule content fetch. Clients send one or more rule_ids in a
/// POST; the response carries a per-id item, each either populated or
/// tagged with a per-item error. TUI also uses this endpoint for
/// single selected-rule loads so content traffic has one request
/// shape.
pub fn handleBatchRuleContent(ctx: *Server.Context, req: *httpz.Request, res: *httpz.Response) !void {
    const user = auth.authenticate(ctx, req) catch {
        return apiError(res, 401, "UNAUTHORIZED", "invalid or missing token");
    };
    if (!auth.requireScope(user, "artifact:read", res)) return;

    const body = req.json(BatchRuleContentRequest) catch {
        return apiError(res, 400, "BAD_REQUEST", "invalid JSON body");
    } orelse {
        return apiError(res, 400, "BAD_REQUEST", "missing request body");
    };

    if (body.rule_ids.len > BATCH_MAX_IDS) {
        return apiError(res, 400, "BAD_REQUEST", "too many rule_ids");
    }

    const conn = ctx.pool.acquire() catch {
        return apiError(res, 503, "SERVICE_UNAVAILABLE", "database unavailable");
    };
    defer conn.release();

    var items: std.ArrayList(BatchRuleItem) = .empty;
    for (body.rule_ids) |rule_id| {
        // Distinguish query failure (INTERNAL_ERROR) from a missing
        // row (NOT_FOUND). Previously both collapsed into NOT_FOUND,
        // so a transient database outage presented to the client as
        // "every rule is missing" — misleading and impossible to
        // debug without server logs.
        const row_result = conn.row(
            "SELECT rule_id, content_hash, content, path FROM rules WHERE org_id = $1::uuid AND rule_id = $2",
            .{ user.org_id, rule_id },
        ) catch {
            try items.append(req.arena, .{
                .rule_id = try req.arena.dupe(u8, rule_id),
                .@"error" = "INTERNAL_ERROR",
            });
            continue;
        };
        var row = row_result orelse {
            try items.append(req.arena, .{
                .rule_id = try req.arena.dupe(u8, rule_id),
                .@"error" = "NOT_FOUND",
            });
            continue;
        };
        // Defer the release so a row.get / dupe failure mid-iteration
        // still returns the pg row handle to the pool.
        defer row.deinit() catch {};

        const pid = try req.arena.dupe(u8, try row.get([]const u8, 0));
        const content_hash = try req.arena.dupe(u8, try row.get([]const u8, 1));
        const content = try req.arena.dupe(u8, try row.get([]const u8, 2));
        const path = try req.arena.dupe(u8, try row.get([]const u8, 3));
        try items.append(req.arena, .{
            .rule_id = pid,
            .path = path,
            .content_hash = content_hash,
            .body = content,
        });
    }

    try res.json(BatchRuleContentResponse{ .items = items.items }, .{});
}

pub fn handleListBundles(ctx: *Server.Context, req: *httpz.Request, res: *httpz.Response) !void {
    const user = auth.authenticate(ctx, req) catch {
        return apiError(res, 401, "UNAUTHORIZED", "invalid or missing token");
    };
    if (!auth.requireScope(user, "artifact:read", res)) return;

    const conn = ctx.pool.acquire() catch {
        return apiError(res, 503, "SERVICE_UNAVAILABLE", "database unavailable");
    };
    defer conn.release();

    var result = conn.query(
        \\SELECT b.bundle_id, b.name, b.description, b.updated_at::text, count(bp.rule_id)::bigint,
        \\       COALESCE(string_agg(bp.rule_id, E'\n' ORDER BY bp.rule_id), '')
        \\FROM bundles b
        \\LEFT JOIN bundle_rules bp ON bp.bundle_id = b.bundle_id
        \\WHERE b.org_id = $1::uuid
        \\GROUP BY b.bundle_id, b.name, b.description, b.updated_at
        \\ORDER BY b.name
    ,
        .{user.org_id},
    ) catch {
        return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
    };
    defer result.deinit();

    var list: std.ArrayList(BundleMeta) = .empty;
    while (try result.next()) |row| {
        const rule_ids_raw = try row.get([]const u8, 5);
        try list.append(req.arena, .{
            .bundle_id = try req.arena.dupe(u8, try row.get([]const u8, 0)),
            .name = try req.arena.dupe(u8, try row.get([]const u8, 1)),
            .description = try req.arena.dupe(u8, try row.get([]const u8, 2)),
            .updated_at = try req.arena.dupe(u8, try row.get([]const u8, 3)),
            .rule_count = try row.get(i64, 4),
            .rule_ids = try splitBundleRuleIds(req.arena, rule_ids_raw),
        });
    }

    try res.json(BundleListResponse{ .bundles = list.items }, .{});
}

fn splitBundleRuleIds(arena: std.mem.Allocator, raw: []const u8) ![]const []const u8 {
    if (raw.len == 0) return &.{};
    var ids: std.ArrayList([]const u8) = .empty;
    var iter = std.mem.splitScalar(u8, raw, '\n');
    while (iter.next()) |id| {
        if (id.len == 0) continue;
        try ids.append(arena, try arena.dupe(u8, id));
    }
    return ids.items;
}

pub fn handleGetBundle(ctx: *Server.Context, req: *httpz.Request, res: *httpz.Response) !void {
    const user = auth.authenticate(ctx, req) catch {
        return apiError(res, 401, "UNAUTHORIZED", "invalid or missing token");
    };
    if (!auth.requireScope(user, "artifact:read", res)) return;

    const name = req.param("name") orelse {
        return apiError(res, 400, "BAD_REQUEST", "name is required");
    };

    const conn = ctx.pool.acquire() catch {
        return apiError(res, 503, "SERVICE_UNAVAILABLE", "database unavailable");
    };
    defer conn.release();

    var row = conn.row(
        "SELECT bundle_id, name, description, created_at::text, updated_at::text FROM bundles WHERE org_id = $1::uuid AND name = $2",
        .{ user.org_id, name },
    ) catch {
        return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
    } orelse {
        return apiError(res, 404, "NOT_FOUND", "bundle not found");
    };

    const bundle_id = try req.arena.dupe(u8, try row.get([]const u8, 0));
    const bundle_name = try req.arena.dupe(u8, try row.get([]const u8, 1));
    const description = try req.arena.dupe(u8, try row.get([]const u8, 2));
    const created_at = try req.arena.dupe(u8, try row.get([]const u8, 3));
    const updated_at = try req.arena.dupe(u8, try row.get([]const u8, 4));
    row.deinit() catch {};

    var rule_result = conn.query(
        "SELECT rule_id FROM bundle_rules WHERE bundle_id = $1",
        .{bundle_id},
    ) catch {
        return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
    };
    defer rule_result.deinit();

    var ids: std.ArrayList([]const u8) = .empty;
    while (try rule_result.next()) |prow| {
        try ids.append(req.arena, try req.arena.dupe(u8, try prow.get([]const u8, 0)));
    }

    try res.json(.{
        .bundle_id = bundle_id,
        .name = bundle_name,
        .description = description,
        .rule_ids = ids.items,
        .created_at = created_at,
        .updated_at = updated_at,
    }, .{});
}

const CreateBundleRequest = artifact_api.CreateBundleRequest;

pub fn handleCreateBundle(ctx: *Server.Context, req: *httpz.Request, res: *httpz.Response) !void {
    const user = auth.authenticate(ctx, req) catch {
        return apiError(res, 401, "UNAUTHORIZED", "invalid or missing token");
    };
    if (!auth.requireScope(user, "bundle:write", res)) return;
    if (!std.mem.eql(u8, user.role, "maintainer")) {
        return apiError(res, 403, "FORBIDDEN", "maintainer role required");
    }

    const body = req.json(CreateBundleRequest) catch {
        return apiError(res, 400, "BAD_REQUEST", "invalid JSON body");
    } orelse {
        return apiError(res, 400, "BAD_REQUEST", "missing request body");
    };

    const conn = ctx.pool.acquire() catch {
        return apiError(res, 503, "SERVICE_UNAVAILABLE", "database unavailable");
    };
    defer conn.release();

    // Validate all rule_ids exist
    for (body.rule_ids) |pid| {
        var exists = conn.row(
            "SELECT 1 FROM rules WHERE org_id = $1::uuid AND rule_id = $2",
            .{ user.org_id, pid },
        ) catch {
            return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
        };
        if (exists) |*r| {
            r.deinit() catch {};
        } else {
            return apiError(res, 400, "BAD_REQUEST", "rule_id not found in Artifact");
        }
    }

    // Generate bundle_id
    var rand_bytes: [16]u8 = undefined;
    std.crypto.random.bytes(&rand_bytes);
    var bid_buf: [36]u8 = undefined;
    @memcpy(bid_buf[0..4], "bnd-");
    const hex = "0123456789abcdef";
    for (rand_bytes, 0..) |byte, i| {
        bid_buf[4 + i * 2] = hex[byte >> 4];
        bid_buf[4 + i * 2 + 1] = hex[byte & 0x0f];
    }
    const bundle_id: []const u8 = &bid_buf;

    const desc = body.description orelse "";
    conn.begin() catch {
        return apiError(res, 500, "INTERNAL_ERROR", "failed to begin transaction");
    };

    _ = conn.exec(
        "INSERT INTO bundles (bundle_id, org_id, name, description) VALUES ($1, $2::uuid, $3, $4)",
        .{ bundle_id, user.org_id, body.name, desc },
    ) catch {
        conn.rollback() catch {};
        if (conn.err) |pg_err| {
            if (std.mem.indexOf(u8, pg_err.message, "unique") != null or
                std.mem.indexOf(u8, pg_err.message, "duplicate") != null)
            {
                return apiError(res, 409, "CONFLICT", "bundle with this name already exists");
            }
        }
        return apiError(res, 500, "INTERNAL_ERROR", "database insert failed");
    };

    for (body.rule_ids) |pid| {
        _ = conn.exec(
            "INSERT INTO bundle_rules (bundle_id, rule_id) VALUES ($1, $2)",
            .{ bundle_id, pid },
        ) catch {
            conn.rollback() catch {};
            return apiError(res, 500, "INTERNAL_ERROR", "database insert failed");
        };
    }

    conn.commit() catch {
        conn.rollback() catch {};
        return apiError(res, 500, "INTERNAL_ERROR", "failed to commit transaction");
    };

    res.status = 201;
    try res.json(.{
        .bundle_id = bundle_id,
        .name = body.name,
        .description = desc,
        .rule_ids = body.rule_ids,
    }, .{});
}

const UpdateBundleRequest = artifact_api.UpdateBundleRequest;

pub fn handleUpdateBundle(ctx: *Server.Context, req: *httpz.Request, res: *httpz.Response) !void {
    const user = auth.authenticate(ctx, req) catch {
        return apiError(res, 401, "UNAUTHORIZED", "invalid or missing token");
    };
    if (!auth.requireScope(user, "bundle:write", res)) return;
    if (!std.mem.eql(u8, user.role, "maintainer")) {
        return apiError(res, 403, "FORBIDDEN", "maintainer role required");
    }

    const name = req.param("name") orelse {
        return apiError(res, 400, "BAD_REQUEST", "name is required");
    };

    const body = req.json(UpdateBundleRequest) catch {
        return apiError(res, 400, "BAD_REQUEST", "invalid JSON body");
    } orelse {
        return apiError(res, 400, "BAD_REQUEST", "missing request body");
    };

    const conn = ctx.pool.acquire() catch {
        return apiError(res, 503, "SERVICE_UNAVAILABLE", "database unavailable");
    };
    defer conn.release();

    // Find bundle
    var row = conn.row(
        "SELECT bundle_id FROM bundles WHERE org_id = $1::uuid AND name = $2",
        .{ user.org_id, name },
    ) catch {
        return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
    } orelse {
        return apiError(res, 404, "NOT_FOUND", "bundle not found");
    };
    const bundle_id = try req.arena.dupe(u8, try row.get([]const u8, 0));
    row.deinit() catch {};

    if (body.rule_ids) |pids| {
        for (pids) |pid| {
            var exists2 = conn.row(
                "SELECT 1 FROM rules WHERE org_id = $1::uuid AND rule_id = $2",
                .{ user.org_id, pid },
            ) catch {
                return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
            };
            if (exists2) |*r| {
                r.deinit() catch {};
            } else {
                return apiError(res, 400, "BAD_REQUEST", "rule_id not found in Artifact");
            }
        }
    }

    conn.begin() catch {
        return apiError(res, 500, "INTERNAL_ERROR", "failed to begin transaction");
    };

    if (body.name) |new_name| {
        _ = conn.exec(
            "UPDATE bundles SET name = $1, updated_at = now() WHERE bundle_id = $2",
            .{ new_name, bundle_id },
        ) catch {
            conn.rollback() catch {};
            if (conn.err) |pg_err| {
                if (std.mem.indexOf(u8, pg_err.message, "unique") != null or
                    std.mem.indexOf(u8, pg_err.message, "duplicate") != null)
                {
                    return apiError(res, 409, "CONFLICT", "bundle with this name already exists");
                }
            }
            return apiError(res, 500, "INTERNAL_ERROR", "database update failed");
        };
    }

    if (body.description) |desc| {
        _ = conn.exec(
            "UPDATE bundles SET description = $1, updated_at = now() WHERE bundle_id = $2",
            .{ desc, bundle_id },
        ) catch {
            conn.rollback() catch {};
            return apiError(res, 500, "INTERNAL_ERROR", "database update failed");
        };
    }

    // Replace rule_ids if provided
    if (body.rule_ids) |pids| {
        _ = conn.exec("DELETE FROM bundle_rules WHERE bundle_id = $1", .{bundle_id}) catch {
            conn.rollback() catch {};
            return apiError(res, 500, "INTERNAL_ERROR", "database update failed");
        };
        for (pids) |pid| {
            _ = conn.exec(
                "INSERT INTO bundle_rules (bundle_id, rule_id) VALUES ($1, $2)",
                .{ bundle_id, pid },
            ) catch {
                conn.rollback() catch {};
                return apiError(res, 500, "INTERNAL_ERROR", "database update failed");
            };
        }
        _ = conn.exec(
            "UPDATE bundles SET updated_at = now() WHERE bundle_id = $1",
            .{bundle_id},
        ) catch {
            conn.rollback() catch {};
            return apiError(res, 500, "INTERNAL_ERROR", "database update failed");
        };
    }

    conn.commit() catch {
        conn.rollback() catch {};
        return apiError(res, 500, "INTERNAL_ERROR", "failed to commit transaction");
    };

    try res.json(.{ .name = body.name orelse name, .updated = true }, .{});
}

pub fn handleDeleteBundle(ctx: *Server.Context, req: *httpz.Request, res: *httpz.Response) !void {
    const user = auth.authenticate(ctx, req) catch {
        return apiError(res, 401, "UNAUTHORIZED", "invalid or missing token");
    };
    if (!auth.requireScope(user, "bundle:write", res)) return;
    if (!std.mem.eql(u8, user.role, "maintainer")) {
        return apiError(res, 403, "FORBIDDEN", "maintainer role required");
    }

    const name = req.param("name") orelse {
        return apiError(res, 400, "BAD_REQUEST", "name is required");
    };

    const conn = ctx.pool.acquire() catch {
        return apiError(res, 503, "SERVICE_UNAVAILABLE", "database unavailable");
    };
    defer conn.release();

    var row = conn.row(
        "SELECT bundle_id FROM bundles WHERE org_id = $1::uuid AND name = $2",
        .{ user.org_id, name },
    ) catch {
        return apiError(res, 500, "INTERNAL_ERROR", "database query failed");
    } orelse {
        return apiError(res, 404, "NOT_FOUND", "bundle not found");
    };
    const bundle_id = try req.arena.dupe(u8, try row.get([]const u8, 0));
    row.deinit() catch {};

    conn.begin() catch {
        return apiError(res, 500, "INTERNAL_ERROR", "failed to begin transaction");
    };

    _ = conn.exec("DELETE FROM bundle_rules WHERE bundle_id = $1", .{bundle_id}) catch {
        conn.rollback() catch {};
        return apiError(res, 500, "INTERNAL_ERROR", "database delete failed");
    };
    _ = conn.exec("DELETE FROM bundles WHERE bundle_id = $1", .{bundle_id}) catch {
        conn.rollback() catch {};
        return apiError(res, 500, "INTERNAL_ERROR", "database delete failed");
    };

    conn.commit() catch {
        conn.rollback() catch {};
        return apiError(res, 500, "INTERNAL_ERROR", "failed to commit transaction");
    };

    res.status = 204;
}
