//! Declarative endpoint specs. Each constant describes one Hub endpoint:
//! method, path, body shape, response parser. Consumers hand one of these
//! specs — along with the PendingRequest slot that will receive the
//! result — to `dispatchFromState`, which extracts transport credentials
//! from `ApiState` and delegates to the core dispatcher.
//!
//! Adding a new endpoint = adding a new const here plus a PendingRequest
//! field on the appropriate domain state. No thread code, no mutex code,
//! no boilerplate.

const std = @import("std");
const auth_api = @import("clumsies_lib").protocol.auth_api;
const collab_api = @import("clumsies_lib").protocol.collab_api;
const artifact_api = @import("clumsies_lib").protocol.artifact_api;
const workspace_api = @import("clumsies_lib").protocol.workspace_api;

const data = @import("../models/view_types.zig");
const dispatcher = @import("dispatcher.zig");
const request = @import("request.zig");
const state = @import("state.zig");
const model = @import("model.zig");
const parse = @import("parse.zig");

pub const create_workspace = dispatcher.RequestSpec(
    workspace_api.CreateWorkspaceRequest,
    workspace_api.CreateWorkspaceResponse,
){
    .method = .POST,
    .path_builder = dispatcher.staticPath(workspace_api.CreateWorkspaceRequest, "/api/workspaces"),
    .body_builder = dispatcher.jsonBody(workspace_api.CreateWorkspaceRequest),
    .parse_ok = dispatcher.jsonParser(workspace_api.CreateWorkspaceRequest, workspace_api.CreateWorkspaceResponse),
};

pub const PathParams = struct { path: []const u8 };
pub const BatchRuleContentParams = struct { rule_ids: []const []const u8 };
pub const BatchWorkspaceRuleContentParams = struct { ws_id: []const u8, rule_ids: []const []const u8 };
pub const BatchWorkspaceContextContentParams = struct { ws_id: []const u8, paths: []const []const u8 };
pub const WorkspaceIdParams = struct { ws_id: []const u8 };
pub const WorkspaceRulesParams = struct { ws_id: []const u8, rule_ids: []const []const u8 };
pub const UpdateWorkspaceParams = struct { ws_id: []const u8, name: []const u8, description: []const u8 };
pub const PrIdParams = struct { pr_id: []const u8, target_kind: data.PrTargetKind = .rule, ws_id: ?[]const u8 = null };
pub const ReviewPrsParams = struct { target_kind: ?data.PrTargetKind = null, status: []const u8 = "open" };

pub const WorkspaceContextPayload = state.WorkspaceContextPayload;
pub const WorkspaceManifestPayload = state.WorkspaceManifestPayload;
pub const WorkspaceContextContentBatchPayload = state.WorkspaceContextContentBatchPayload;
pub const WorkspaceMembersPayload = state.WorkspaceMembersPayload;
pub const PrCommentsPayload = state.PrCommentsPayload;
pub const CreateRulePrResponse = state.CreateRulePrResponse;
pub const CreateContextPrResponse = state.CreateContextPrResponse;

/// Parameters for creating a rule PR with a single operation.
/// Mirrors CreateContextPrParams so the composer submit path is
/// symmetric across the two categories. Multi-op PRs remain a
/// follow-up; the composer UI submits one draft at a time. Which
/// fields must be non-null depends on `operation_type`: update,
/// rename, and delete carry `rule_id`; create carries `path`; rename
/// carries `new_path`; update and rename carry `base_hash`.
pub const CreateRulePrParams = struct {
    ws_id: ?[]const u8 = null,
    title: []const u8,
    body: []const u8,
    operation_type: []const u8,
    rule_id: ?[]const u8 = null,
    path: ?[]const u8 = null,
    new_path: ?[]const u8 = null,
    content: ?[]const u8 = null,
    base_hash: ?[]const u8 = null,
};

pub const CreateRulePrOperation = struct {
    operation_type: []const u8,
    rule_id: ?[]const u8 = null,
    base_hash: ?[]const u8 = null,
    content: ?[]const u8 = null,
    path: ?[]const u8 = null,
    new_path: ?[]const u8 = null,
};

pub const CreateRulePrBatchParams = struct {
    ws_id: ?[]const u8 = null,
    title: []const u8,
    body: []const u8,
    operations: []const CreateRulePrOperation,
};

/// Parameters for creating a context PR. Mirrors the rule PR shape
/// but against a workspace-scoped endpoint. `context_id` identifies
/// an existing file (update/rename/delete); create-ops leave it null
/// and populate `path` instead. Rename ops populate `new_path`.
pub const CreateContextPrParams = struct {
    ws_id: []const u8,
    title: []const u8,
    body: []const u8,
    operation_type: []const u8,
    context_id: ?[]const u8 = null,
    path: ?[]const u8 = null,
    new_path: ?[]const u8 = null,
    content: ?[]const u8 = null,
    base_hash: ?[]const u8 = null,
};

pub const CreateContextPrOperation = struct {
    operation_type: []const u8,
    context_id: ?[]const u8 = null,
    base_hash: ?[]const u8 = null,
    content: ?[]const u8 = null,
    path: ?[]const u8 = null,
    new_path: ?[]const u8 = null,
};

pub const CreateContextPrBatchParams = struct {
    ws_id: []const u8,
    title: []const u8,
    body: []const u8,
    operations: []const CreateContextPrOperation,
};

pub const artifact_rule_content_batch = dispatcher.RequestSpec(
    BatchRuleContentParams,
    artifact_api.BatchRuleContentResponse,
){
    .method = .POST,
    .path_builder = dispatcher.staticPath(BatchRuleContentParams, "/api/artifact/rules/content"),
    .body_builder = batchRuleContentBody,
    .parse_ok = dispatcher.jsonParser(BatchRuleContentParams, artifact_api.BatchRuleContentResponse),
};

pub const workspace_rule_content_batch = dispatcher.RequestSpec(
    BatchWorkspaceRuleContentParams,
    artifact_api.BatchRuleContentResponse,
){
    .method = .POST,
    .path_builder = workspaceRuleContentBatchPath,
    .body_builder = batchWorkspaceRuleContentBody,
    .parse_ok = dispatcher.jsonParser(BatchWorkspaceRuleContentParams, artifact_api.BatchRuleContentResponse),
};

pub const review_prs = dispatcher.RequestSpec(
    ReviewPrsParams,
    []const model.RulePr,
){
    .method = .GET,
    .path_builder = reviewPrsPath,
    .parse_ok = parseReviewPrsPayload,
};

pub const workspace_context_content_batch = dispatcher.RequestSpec(
    BatchWorkspaceContextContentParams,
    WorkspaceContextContentBatchPayload,
){
    .method = .POST,
    .path_builder = workspaceContextContentBatchPath,
    .body_builder = batchWorkspaceContextContentBody,
    .parse_ok = parseWorkspaceContextContentBatchPayload,
};

pub const workspace_context = dispatcher.RequestSpec(
    WorkspaceIdParams,
    WorkspaceContextPayload,
){
    .method = .GET,
    .path_builder = workspaceContextFilesPath,
    .parse_ok = parseWorkspaceContextPayload,
};

pub const workspace_manifest = dispatcher.RequestSpec(
    WorkspaceIdParams,
    WorkspaceManifestPayload,
){
    .method = .GET,
    .path_builder = workspaceManifestPath,
    .parse_ok = parseWorkspaceManifestPayload,
};

pub const workspace_members = dispatcher.RequestSpec(
    WorkspaceIdParams,
    WorkspaceMembersPayload,
){
    .method = .GET,
    .path_builder = workspaceMembersPath,
    .parse_ok = parseWorkspaceMembersPayload,
};

pub const update_workspace = dispatcher.RequestSpec(
    UpdateWorkspaceParams,
    workspace_api.CreateWorkspaceResponse,
){
    .method = .PATCH,
    .path_builder = updateWorkspacePath,
    .body_builder = updateWorkspaceBody,
    .parse_ok = dispatcher.jsonParser(UpdateWorkspaceParams, workspace_api.CreateWorkspaceResponse),
};

pub const delete_workspace = dispatcher.RequestSpec(WorkspaceIdParams, void){
    .method = .DELETE,
    .path_builder = workspacePath,
    .body_builder = null,
    .parse_ok = dispatcher.parseVoid(WorkspaceIdParams),
};

pub const import_workspace_rules = dispatcher.RequestSpec(
    WorkspaceRulesParams,
    workspace_api.WorkspaceRulesResponse,
){
    .method = .POST,
    .path_builder = workspaceRulesPath,
    .body_builder = workspaceRulesBody,
    .parse_ok = dispatcher.jsonParser(WorkspaceRulesParams, workspace_api.WorkspaceRulesResponse),
};

pub const detach_workspace_rules = dispatcher.RequestSpec(
    WorkspaceRulesParams,
    workspace_api.WorkspaceRulesResponse,
){
    .method = .POST,
    .path_builder = detachWorkspaceRulesPath,
    .body_builder = workspaceRulesBody,
    .parse_ok = dispatcher.jsonParser(WorkspaceRulesParams, workspace_api.WorkspaceRulesResponse),
};

pub const pr_detail = dispatcher.RequestSpec(
    PrIdParams,
    collab_api.RulePrDetailResponse,
){
    .method = .GET,
    .path_builder = prDetailPath,
    .parse_ok = dispatcher.jsonParser(PrIdParams, collab_api.RulePrDetailResponse),
};

pub const pr_comments = dispatcher.RequestSpec(
    PrIdParams,
    PrCommentsPayload,
){
    .method = .GET,
    .path_builder = prCommentsPath,
    .parse_ok = parsePrCommentsPayload,
};

pub const EmptyParams = struct {};

pub const SubmitCommentParams = struct {
    pr_id: []const u8,
    target_kind: data.PrTargetKind = .rule,
    ws_id: ?[]const u8 = null,
    body: []const u8,
};

pub const PrActionParams = struct {
    pr_id: []const u8,
    target_kind: data.PrTargetKind = .rule,
    ws_id: ?[]const u8 = null,
    action: []const u8,
};

pub const MemberIdParams = struct {
    user_id: []const u8,
};

pub const ChangeMemberRoleParams = struct {
    user_id: []const u8,
    role: []const u8,
};

pub const WorkspaceMemberIdParams = struct {
    ws_id: []const u8,
    user_id: []const u8,
};

pub const WorkspaceMemberRoleParams = struct {
    ws_id: []const u8,
    user_id: []const u8,
    role: []const u8,
};

pub const sign_out = dispatcher.RequestSpec(EmptyParams, void){
    .method = .DELETE,
    .path_builder = dispatcher.staticPath(EmptyParams, "/api/auth/token"),
    .body_builder = null,
    .parse_ok = dispatcher.parseVoid(EmptyParams),
};

pub const update_profile = dispatcher.RequestSpec(auth_api.UpdateProfileRequest, auth_api.UpdateProfileResponse){
    .method = .PATCH,
    .path_builder = dispatcher.staticPath(auth_api.UpdateProfileRequest, "/api/auth/me"),
    .body_builder = dispatcher.jsonBody(auth_api.UpdateProfileRequest),
    .parse_ok = dispatcher.jsonParser(auth_api.UpdateProfileRequest, auth_api.UpdateProfileResponse),
};

pub const invite_member = dispatcher.RequestSpec(auth_api.InviteMemberRequest, auth_api.InviteMemberResponse){
    .method = .POST,
    .path_builder = dispatcher.staticPath(auth_api.InviteMemberRequest, "/api/members"),
    .body_builder = dispatcher.jsonBody(auth_api.InviteMemberRequest),
    .parse_ok = dispatcher.jsonParser(auth_api.InviteMemberRequest, auth_api.InviteMemberResponse),
};

pub const change_member_role = dispatcher.RequestSpec(ChangeMemberRoleParams, void){
    .method = .PATCH,
    .path_builder = memberPath(ChangeMemberRoleParams),
    .body_builder = changeMemberRoleBody,
    .parse_ok = dispatcher.parseVoid(ChangeMemberRoleParams),
};

pub const remove_member = dispatcher.RequestSpec(MemberIdParams, void){
    .method = .DELETE,
    .path_builder = memberPath(MemberIdParams),
    .body_builder = null,
    .parse_ok = dispatcher.parseVoid(MemberIdParams),
};

pub const add_workspace_member = dispatcher.RequestSpec(WorkspaceMemberRoleParams, void){
    .method = .POST,
    .path_builder = addWorkspaceMemberPath,
    .body_builder = workspaceMemberRoleBody,
    .parse_ok = dispatcher.parseVoid(WorkspaceMemberRoleParams),
};

pub const change_workspace_member_role = dispatcher.RequestSpec(WorkspaceMemberRoleParams, void){
    .method = .PATCH,
    .path_builder = workspaceMemberPath(WorkspaceMemberRoleParams),
    .body_builder = workspaceMemberChangeRoleBody,
    .parse_ok = dispatcher.parseVoid(WorkspaceMemberRoleParams),
};

pub const remove_workspace_member = dispatcher.RequestSpec(WorkspaceMemberIdParams, void){
    .method = .DELETE,
    .path_builder = workspaceMemberPath(WorkspaceMemberIdParams),
    .body_builder = null,
    .parse_ok = dispatcher.parseVoid(WorkspaceMemberIdParams),
};

pub const submit_comment = dispatcher.RequestSpec(SubmitCommentParams, void){
    .method = .POST,
    .path_builder = submitCommentPath,
    .body_builder = submitCommentBody,
    .parse_ok = dispatcher.parseVoid(SubmitCommentParams),
};

pub const pr_action = dispatcher.RequestSpec(PrActionParams, void){
    .method = .PUT,
    .path_builder = prActionPath,
    .body_builder = prActionBody,
    .parse_ok = dispatcher.parseVoid(PrActionParams),
};

pub const create_rule_pr = dispatcher.RequestSpec(CreateRulePrParams, CreateRulePrResponse){
    .method = .POST,
    .path_builder = dispatcher.staticPath(CreateRulePrParams, "/api/prs"),
    .body_builder = createRulePrBody,
    .parse_ok = dispatcher.jsonParser(CreateRulePrParams, CreateRulePrResponse),
};

pub const create_rule_pr_batch = dispatcher.RequestSpec(CreateRulePrBatchParams, CreateRulePrResponse){
    .method = .POST,
    .path_builder = dispatcher.staticPath(CreateRulePrBatchParams, "/api/prs"),
    .body_builder = createRulePrBatchBody,
    .parse_ok = dispatcher.jsonParser(CreateRulePrBatchParams, CreateRulePrResponse),
};

pub const create_context_pr = dispatcher.RequestSpec(CreateContextPrParams, CreateContextPrResponse){
    .method = .POST,
    .path_builder = createContextPrPath,
    .body_builder = createContextPrBody,
    .parse_ok = dispatcher.jsonParser(CreateContextPrParams, CreateContextPrResponse),
};

pub const create_context_pr_batch = dispatcher.RequestSpec(CreateContextPrBatchParams, CreateContextPrResponse){
    .method = .POST,
    .path_builder = createContextPrBatchPath,
    .body_builder = createContextPrBatchBody,
    .parse_ok = dispatcher.jsonParser(CreateContextPrBatchParams, CreateContextPrResponse),
};

fn createRulePrBody(alloc: std.mem.Allocator, p: CreateRulePrParams) anyerror![]const u8 {
    // Every op-type field is optional on the wire — the hub tolerates
    // `"field": null` as equivalent to "field absent" (see
    // hub/collab.zig `Operation`), so emitting all fields keeps the
    // body shape uniform and lets the validator branch on `type`.
    // submit code in shell.zig decides which fields to populate per
    // operation_type based on the draft index entry; this function
    // just serializes the decision.
    const Op = struct {
        type: []const u8,
        rule_id: ?[]const u8 = null,
        base_hash: ?[]const u8 = null,
        content: ?[]const u8 = null,
        path: ?[]const u8 = null,
        new_path: ?[]const u8 = null,
    };
    const Body = struct {
        ws_id: ?[]const u8 = null,
        title: []const u8,
        body: []const u8,
        operations: []const Op,
    };
    const ops = [_]Op{.{
        .type = p.operation_type,
        .rule_id = p.rule_id,
        .base_hash = p.base_hash,
        .content = p.content,
        .path = p.path,
        .new_path = p.new_path,
    }};
    return std.json.Stringify.valueAlloc(alloc, Body{
        .ws_id = p.ws_id,
        .title = p.title,
        .body = p.body,
        .operations = &ops,
    }, .{});
}

fn createRulePrBatchBody(alloc: std.mem.Allocator, p: CreateRulePrBatchParams) anyerror![]const u8 {
    const Op = struct {
        type: []const u8,
        rule_id: ?[]const u8 = null,
        base_hash: ?[]const u8 = null,
        content: ?[]const u8 = null,
        path: ?[]const u8 = null,
        new_path: ?[]const u8 = null,
    };
    const Body = struct {
        ws_id: ?[]const u8 = null,
        title: []const u8,
        body: []const u8,
        operations: []const Op,
    };
    var ops: std.ArrayList(Op) = .empty;
    for (p.operations) |op| {
        try ops.append(alloc, .{
            .type = op.operation_type,
            .rule_id = op.rule_id,
            .base_hash = op.base_hash,
            .content = op.content,
            .path = op.path,
            .new_path = op.new_path,
        });
    }
    defer ops.deinit(alloc);
    return std.json.Stringify.valueAlloc(alloc, Body{
        .ws_id = p.ws_id,
        .title = p.title,
        .body = p.body,
        .operations = ops.items,
    }, .{});
}

fn createContextPrPath(alloc: std.mem.Allocator, p: CreateContextPrParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/context/prs", .{p.ws_id});
}

fn createContextPrBatchPath(alloc: std.mem.Allocator, p: CreateContextPrBatchParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/context/prs", .{p.ws_id});
}

fn createContextPrBody(alloc: std.mem.Allocator, p: CreateContextPrParams) anyerror![]const u8 {
    const Op = struct {
        type: []const u8,
        context_id: ?[]const u8,
        base_hash: ?[]const u8,
        content: ?[]const u8,
        path: ?[]const u8,
        new_path: ?[]const u8,
    };
    const Body = struct {
        title: []const u8,
        body: []const u8,
        operations: []const Op,
    };
    const ops = [_]Op{.{
        .type = p.operation_type,
        .context_id = p.context_id,
        .base_hash = p.base_hash,
        .content = p.content,
        .path = p.path,
        .new_path = p.new_path,
    }};
    return std.json.Stringify.valueAlloc(alloc, Body{
        .title = p.title,
        .body = p.body,
        .operations = &ops,
    }, .{});
}

fn createContextPrBatchBody(alloc: std.mem.Allocator, p: CreateContextPrBatchParams) anyerror![]const u8 {
    const Op = struct {
        type: []const u8,
        context_id: ?[]const u8,
        base_hash: ?[]const u8,
        content: ?[]const u8,
        path: ?[]const u8,
        new_path: ?[]const u8,
    };
    const Body = struct {
        title: []const u8,
        body: []const u8,
        operations: []const Op,
    };
    var ops: std.ArrayList(Op) = .empty;
    defer ops.deinit(alloc);
    for (p.operations) |op| {
        try ops.append(alloc, .{
            .type = op.operation_type,
            .context_id = op.context_id,
            .base_hash = op.base_hash,
            .content = op.content,
            .path = op.path,
            .new_path = op.new_path,
        });
    }
    return std.json.Stringify.valueAlloc(alloc, Body{
        .title = p.title,
        .body = p.body,
        .operations = ops.items,
    }, .{});
}

fn submitCommentPath(alloc: std.mem.Allocator, p: SubmitCommentParams) anyerror![]const u8 {
    if (p.target_kind == .context) {
        return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/context/prs/{s}/comments", .{ try requireContextWsId(p.ws_id), p.pr_id });
    }
    return std.fmt.allocPrint(alloc, "/api/prs/{s}/comments", .{p.pr_id});
}

fn submitCommentBody(alloc: std.mem.Allocator, p: SubmitCommentParams) anyerror![]const u8 {
    const Payload = struct { body: []const u8 };
    return std.json.Stringify.valueAlloc(alloc, Payload{ .body = p.body }, .{});
}

fn prActionPath(alloc: std.mem.Allocator, p: PrActionParams) anyerror![]const u8 {
    if (p.target_kind == .context) {
        return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/context/prs/{s}", .{ try requireContextWsId(p.ws_id), p.pr_id });
    }
    return std.fmt.allocPrint(alloc, "/api/prs/{s}", .{p.pr_id});
}

fn prActionBody(alloc: std.mem.Allocator, p: PrActionParams) anyerror![]const u8 {
    const Payload = struct { action: []const u8 };
    const action = if (p.target_kind == .context and std.mem.eql(u8, p.action, "accept")) "merge" else p.action;
    return std.json.Stringify.valueAlloc(alloc, Payload{ .action = action }, .{});
}

fn changeMemberRoleBody(alloc: std.mem.Allocator, p: ChangeMemberRoleParams) anyerror![]const u8 {
    return std.json.Stringify.valueAlloc(alloc, auth_api.ChangeMemberRoleRequest{ .role = p.role }, .{});
}

fn workspaceMemberRoleBody(alloc: std.mem.Allocator, p: WorkspaceMemberRoleParams) anyerror![]const u8 {
    const Payload = struct { user_id: []const u8, role: []const u8 };
    return std.json.Stringify.valueAlloc(alloc, Payload{ .user_id = p.user_id, .role = p.role }, .{});
}

fn workspaceMemberChangeRoleBody(alloc: std.mem.Allocator, p: WorkspaceMemberRoleParams) anyerror![]const u8 {
    return std.json.Stringify.valueAlloc(alloc, auth_api.ChangeMemberRoleRequest{ .role = p.role }, .{});
}

fn memberPath(comptime ReqT: type) *const fn (std.mem.Allocator, ReqT) anyerror![]const u8 {
    return struct {
        fn build(alloc: std.mem.Allocator, p: ReqT) anyerror![]const u8 {
            return std.fmt.allocPrint(alloc, "/api/members/{s}", .{p.user_id});
        }
    }.build;
}

fn addWorkspaceMemberPath(alloc: std.mem.Allocator, p: WorkspaceMemberRoleParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/members", .{p.ws_id});
}

fn workspaceMemberPath(comptime ReqT: type) *const fn (std.mem.Allocator, ReqT) anyerror![]const u8 {
    return struct {
        fn build(alloc: std.mem.Allocator, p: ReqT) anyerror![]const u8 {
            return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/members/{s}", .{ p.ws_id, p.user_id });
        }
    }.build;
}

fn batchRuleContentBody(alloc: std.mem.Allocator, p: BatchRuleContentParams) anyerror![]const u8 {
    return std.json.Stringify.valueAlloc(alloc, artifact_api.BatchRuleContentRequest{
        .rule_ids = p.rule_ids,
    }, .{});
}

fn workspaceRuleContentBatchPath(alloc: std.mem.Allocator, p: BatchWorkspaceRuleContentParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/rules/content", .{p.ws_id});
}

fn batchWorkspaceRuleContentBody(alloc: std.mem.Allocator, p: BatchWorkspaceRuleContentParams) anyerror![]const u8 {
    return std.json.Stringify.valueAlloc(alloc, artifact_api.BatchRuleContentRequest{
        .rule_ids = p.rule_ids,
    }, .{});
}

fn reviewPrsPath(alloc: std.mem.Allocator, p: ReviewPrsParams) anyerror![]const u8 {
    const target = if (p.target_kind) |kind| kind.label() else "";
    return std.fmt.allocPrint(alloc, "/api/prs?target={s}&status={s}", .{ target, p.status });
}

fn workspaceContextContentBatchPath(alloc: std.mem.Allocator, p: BatchWorkspaceContextContentParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/context/content", .{p.ws_id});
}

fn batchWorkspaceContextContentBody(alloc: std.mem.Allocator, p: BatchWorkspaceContextContentParams) anyerror![]const u8 {
    return std.json.Stringify.valueAlloc(alloc, workspace_api.BatchContextContentRequest{
        .paths = p.paths,
    }, .{});
}

fn workspaceContextFilesPath(alloc: std.mem.Allocator, p: WorkspaceIdParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/context", .{p.ws_id});
}

fn workspaceManifestPath(alloc: std.mem.Allocator, p: WorkspaceIdParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/manifest", .{p.ws_id});
}

fn workspaceMembersPath(alloc: std.mem.Allocator, p: WorkspaceIdParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/members", .{p.ws_id});
}

fn workspacePath(alloc: std.mem.Allocator, p: WorkspaceIdParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}", .{p.ws_id});
}

fn workspaceRulesPath(alloc: std.mem.Allocator, p: WorkspaceRulesParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/rules", .{p.ws_id});
}

fn detachWorkspaceRulesPath(alloc: std.mem.Allocator, p: WorkspaceRulesParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/rules/detach", .{p.ws_id});
}

fn workspaceRulesBody(alloc: std.mem.Allocator, p: WorkspaceRulesParams) anyerror![]const u8 {
    return std.json.Stringify.valueAlloc(alloc, workspace_api.WorkspaceRulesRequest{
        .rule_ids = p.rule_ids,
    }, .{});
}

fn updateWorkspacePath(alloc: std.mem.Allocator, p: UpdateWorkspaceParams) anyerror![]const u8 {
    return std.fmt.allocPrint(alloc, "/api/workspaces/{s}", .{p.ws_id});
}

fn updateWorkspaceBody(alloc: std.mem.Allocator, p: UpdateWorkspaceParams) anyerror![]const u8 {
    return std.json.Stringify.valueAlloc(alloc, workspace_api.UpdateWorkspaceRequest{
        .name = p.name,
        .description = p.description,
    }, .{});
}

fn prDetailPath(alloc: std.mem.Allocator, p: PrIdParams) anyerror![]const u8 {
    if (p.target_kind == .context) {
        return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/context/prs/{s}", .{ try requireContextWsId(p.ws_id), p.pr_id });
    }
    return std.fmt.allocPrint(alloc, "/api/prs/{s}", .{p.pr_id});
}

fn prCommentsPath(alloc: std.mem.Allocator, p: PrIdParams) anyerror![]const u8 {
    if (p.target_kind == .context) {
        return std.fmt.allocPrint(alloc, "/api/workspaces/{s}/context/prs/{s}/comments", .{ try requireContextWsId(p.ws_id), p.pr_id });
    }
    return std.fmt.allocPrint(alloc, "/api/prs/{s}/comments", .{p.pr_id});
}

fn requireContextWsId(ws_id: ?[]const u8) anyerror![]const u8 {
    const value = ws_id orelse return error.MissingWorkspaceId;
    if (value.len == 0) return error.MissingWorkspaceId;
    return value;
}

test "context PR paths require workspace id" {
    const alloc = std.testing.allocator;

    try std.testing.expectError(error.MissingWorkspaceId, prDetailPath(alloc, .{
        .pr_id = "cpr-1",
        .target_kind = .context,
    }));
    try std.testing.expectError(error.MissingWorkspaceId, prCommentsPath(alloc, .{
        .pr_id = "cpr-1",
        .target_kind = .context,
        .ws_id = "",
    }));
    try std.testing.expectError(error.MissingWorkspaceId, submitCommentPath(alloc, .{
        .pr_id = "cpr-1",
        .target_kind = .context,
        .body = "Looks good.",
    }));
    try std.testing.expectError(error.MissingWorkspaceId, prActionPath(alloc, .{
        .pr_id = "cpr-1",
        .target_kind = .context,
        .action = "accept",
    }));
}

test "context PR paths include workspace id when present" {
    const alloc = std.testing.allocator;
    const path = try prDetailPath(alloc, .{
        .pr_id = "cpr-1",
        .target_kind = .context,
        .ws_id = "ws-1",
    });
    defer alloc.free(path);
    try std.testing.expectEqualStrings("/api/workspaces/ws-1/context/prs/cpr-1", path);
}

test "rename PR bodies include new_path" {
    const alloc = std.testing.allocator;

    const rule_body = try createRulePrBody(alloc, .{
        .title = "rename rule",
        .body = "rename rule",
        .operation_type = "rename",
        .rule_id = "p-1",
        .new_path = "new/RULE.md",
        .content = "# Rule\n\nBody.\n",
        .base_hash = "sha256:abc",
    });
    defer alloc.free(rule_body);
    try std.testing.expect(std.mem.indexOf(u8, rule_body, "\"new_path\":\"new/RULE.md\"") != null);

    const context_body = try createContextPrBody(alloc, .{
        .ws_id = "ws-1",
        .title = "rename context",
        .body = "rename context",
        .operation_type = "rename",
        .context_id = "c-1",
        .new_path = "new/context.md",
        .content = "# Context\n\nBody.\n",
        .base_hash = "sha256:def",
    });
    defer alloc.free(context_body);
    try std.testing.expect(std.mem.indexOf(u8, context_body, "\"new_path\":\"new/context.md\"") != null);
}

fn parseReviewPrsPayload(
    alloc: std.mem.Allocator,
    req: ReviewPrsParams,
    body: []const u8,
) anyerror![]const model.RulePr {
    _ = req;
    return parse.parseReviewPrs(alloc, body) orelse error.ParseFailed;
}

fn parseWorkspaceContextPayload(
    alloc: std.mem.Allocator,
    req: WorkspaceIdParams,
    body: []const u8,
) anyerror!WorkspaceContextPayload {
    const files = parse.parseWorkspaceContext(alloc, body) orelse return error.ParseFailed;
    return .{
        .ws_id = try alloc.dupe(u8, req.ws_id),
        .files = files,
    };
}

fn parseWorkspaceManifestPayload(
    alloc: std.mem.Allocator,
    req: WorkspaceIdParams,
    body: []const u8,
) anyerror!WorkspaceManifestPayload {
    const rules = parse.parseManifestRules(alloc, body) orelse return error.ParseFailed;
    return .{
        .ws_id = try alloc.dupe(u8, req.ws_id),
        .rules = rules,
    };
}

fn parseWorkspaceMembersPayload(
    alloc: std.mem.Allocator,
    req: WorkspaceIdParams,
    body: []const u8,
) anyerror!WorkspaceMembersPayload {
    const members = parse.parseWorkspaceMembers(alloc, body) orelse return error.ParseFailed;
    return .{
        .ws_id = try alloc.dupe(u8, req.ws_id),
        .members = members,
    };
}

fn parseWorkspaceContextContentBatchPayload(
    alloc: std.mem.Allocator,
    req: BatchWorkspaceContextContentParams,
    body: []const u8,
) anyerror!WorkspaceContextContentBatchPayload {
    const parsed = try std.json.parseFromSliceLeaky(workspace_api.BatchContextContentResponse, alloc, body, .{
        .allocate = .alloc_always,
        .ignore_unknown_fields = true,
    });
    return .{
        .ws_id = try alloc.dupe(u8, req.ws_id),
        .items = parsed.items,
    };
}

fn parsePrCommentsPayload(
    alloc: std.mem.Allocator,
    req: PrIdParams,
    body: []const u8,
) anyerror!PrCommentsPayload {
    const comments = parse.parseComments(alloc, body) orelse return error.ParseFailed;
    return .{
        .pr_id = try alloc.dupe(u8, req.pr_id),
        .comments = comments,
    };
}

/// Dispatch a request using the transport state already held on
/// `api_state`. Hides the mutex-guarded read of `hub_url` and
/// `access_token`, the ApiState arena allocator, and the thread
/// registry plumbing so consumers stay at the level of "call this spec
/// and land the result in this slot."
///
/// If the ApiState has no hub_url or access_token (not yet authed), the
/// call marks the slot as `network_error` via `tryBegin` + immediate
/// `complete`, so the consumer's next `consume` sees a classified
/// failure instead of silent inaction.
pub fn dispatchFromState(
    comptime ReqT: type,
    comptime RespT: type,
    spec: dispatcher.RequestSpec(ReqT, RespT),
    pending: *request.PendingRequest(dispatcher.Result(RespT)),
    api_state: *state.ApiState,
    req: ReqT,
) void {
    const token_alloc = api_state.backing_allocator;
    var access_token_copy: ?[]const u8 = null;

    api_state.mutex.lock();
    const hub_url = api_state.hub_url;
    const username = api_state.username;
    const access_token = api_state.access_token;
    const refresh_token = api_state.refresh_token;
    if (access_token) |token| {
        access_token_copy = token_alloc.dupe(u8, token) catch null;
    }
    const has_refresh = username != null and refresh_token != null;
    api_state.mutex.unlock();
    defer if (access_token_copy) |token| token_alloc.free(token);

    if (hub_url == null or access_token_copy == null) {
        const gen = pending.tryBegin() orelse return;
        pending.complete(gen, .network_error);
        return;
    }

    dispatcher.dispatch(
        ReqT,
        RespT,
        spec,
        pending,
        &api_state.thread_registry,
        api_state.backing_allocator,
        hub_url.?,
        access_token_copy.?,
        api_state.clientIdHex(),
        if (has_refresh) .{
            .update_ctx = api_state,
            .refresh_fn = refreshAuthTokens,
        } else null,
        api_state.backing_allocator,
        api_state.allocator(),
        req,
    );
}

fn refreshAuthTokens(ctx: *anyopaque, alloc: std.mem.Allocator, previous_access_token: []const u8) anyerror!dispatcher.RefreshTokens {
    const api_state: *state.ApiState = @ptrCast(@alignCast(ctx));
    const tokens = try api_state.refreshAuthTokens(alloc, previous_access_token);
    return .{ .access_token = tokens.access_token, .refresh_token = tokens.refresh_token };
}

pub const health = dispatcher.RequestSpec(EmptyParams, void){
    .method = .GET,
    .path_builder = dispatcher.staticPath(EmptyParams, "/api/health"),
    .body_builder = null,
    .parse_ok = dispatcher.parseVoid(EmptyParams),
};

pub fn dispatchHealthCheck(api_state: *state.ApiState) void {
    api_state.mutex.lock();
    const hub_url = api_state.hub_url orelse {
        api_state.mutex.unlock();
        return;
    };
    api_state.mutex.unlock();

    dispatcher.dispatch(
        EmptyParams,
        void,
        health,
        &api_state.health_pending,
        &api_state.thread_registry,
        api_state.backing_allocator,
        hub_url,
        "",
        api_state.clientIdHex(),
        null,
        api_state.backing_allocator,
        api_state.allocator(),
        .{},
    );
}
