import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";

import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js";
import { DatabaseNotConfiguredError } from "../db/pool.js";
import { getToolsForSession } from "../mcp/tool-runtime.js";
import { mcpToolDefinitions } from "../mcp/tools.js";
import type {
  AgentGrantRecord,
  AgentRecord,
  AgentSetupCodeRecord,
  AgentSessionRecord,
  AgentTokenRecord,
  AgentsRepository,
  CreateTokenGrantInput,
} from "../repositories/agents.js";
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
import { requireInternalAccess } from "../security/internal.js";
import { generateAgentSetupCode, generateAgentToken, generateOntologyAgentToken } from "../security/agent-access.js";

type AgentRouteDeps = {
  agentsRepository: AgentsRepository | null;
  codexInstallChannel: "registry" | "tarball";
  codexNpmSpec: string;
  publicUrl: string;
  internalAccessToken?: string;
  aiWorkspaceRunTokenTtlSeconds: number;
};

const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000;
const OPS_APP_ID = "ops";
const OPS_APP_TITLE = "NODE.DC Ops";
const OPS_MCP_SERVER_NAME = "nodedc_ops_agent";
const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent";
const CODEX_NPM_PACKAGE_VERSION = "0.1.3";
const DEFAULT_OPS_AGENT_GATEWAY = "https://ops-agents.nodedc.ru";
const DEFAULT_SETUP_CODE_TTL_SECONDS = 10 * 60;
const MAX_SETUP_CODE_TTL_SECONDS = 24 * 60 * 60;

function isAllowedAvatarUrl(value: string): boolean {
  if (value.startsWith("data:image/")) {
    return /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(value);
  }

  try {
    const url = new URL(value);
    return url.protocol === "http:" || url.protocol === "https:";
  } catch {
    return false;
  }
}

const agentAvatarUrlSchema = z
  .string()
  .max(MAX_AGENT_AVATAR_URL_LENGTH)
  .refine(isAllowedAvatarUrl, "avatar_url must be http(s) URL or image data URL");

const agentParamsSchema = z.object({
  agentId: z.string().uuid(),
});

const ownerParamsSchema = z.object({
  ownerUserId: z.string().min(1),
});

const ownerAgentParamsSchema = ownerParamsSchema.extend({
  agentId: z.string().uuid(),
});

const tokenParamsSchema = agentParamsSchema.extend({
  tokenId: z.string().uuid(),
});

const ownerTokenParamsSchema = ownerAgentParamsSchema.extend({
  tokenId: z.string().uuid(),
});

const listAgentsQuerySchema = z.object({
  owner_user_id: z.string().min(1).optional(),
});

const createAgentBodySchema = z.object({
  owner_user_id: z.string().min(1),
  owner_email: z.string().email().nullish(),
  display_name: z.string().min(1).max(120),
  avatar_url: agentAvatarUrlSchema.nullish(),
});

const createOwnerAgentBodySchema = z.object({
  owner_email: z.string().email().nullish(),
  display_name: z.string().min(1).max(120),
  avatar_url: agentAvatarUrlSchema.nullish(),
});

const updateOwnerAgentBodySchema = z.object({
  display_name: z.string().min(1).max(120).optional(),
  avatar_url: agentAvatarUrlSchema.nullable().optional(),
  actor_user_id: z.string().min(1).optional(),
});

const upsertGrantBodySchema = z.object({
  workspace_slug: z.string().min(1),
  project_id: z.string().min(1).nullish(),
  scopes: z.array(z.enum(allowedAgentScopes)).min(1),
  mode: z.enum(["voluntary", "reporting"]).default("voluntary"),
  created_by_user_id: z.string().min(1),
});

const replaceWorkspaceGrantsBodySchema = z.object({
  workspace_slug: z.string().min(1),
  project_ids: z.array(z.string().min(1)).min(1),
  scopes: z.array(z.enum(allowedAgentScopes)).min(1),
  mode: z.enum(["voluntary", "reporting"]).default("voluntary"),
  created_by_user_id: z.string().min(1),
});

const replaceProjectGrantsBodySchema = z.object({
  grants: z
    .array(
      z.object({
        workspace_slug: z.string().min(1),
        project_id: z.string().min(1),
      })
    )
    .min(1),
  scopes: z.array(z.enum(allowedAgentScopes)).min(1),
  mode: z.enum(["voluntary", "reporting"]).default("voluntary"),
  created_by_user_id: z.string().min(1),
});

const createTokenBodySchema = z.object({
  name: z.string().min(1).max(120).default("Local Codex token"),
  expires_at: z.string().datetime().nullish(),
});

const createSetupCodeBodySchema = z.object({
  expires_in_seconds: z.number().int().min(60).max(MAX_SETUP_CODE_TTL_SECONDS).default(DEFAULT_SETUP_CODE_TTL_SECONDS),
  token_name: z.string().min(1).max(120).default("Local Codex token"),
});

const redeemSetupCodeBodySchema = z.object({
  setup_code: z.string().min(16).max(160),
});

const actorBodySchema = z.object({
  actor_user_id: z.string().min(1).optional(),
});

const aiWorkspaceEntitlementBodySchema = z.object({
  schemaVersion: z.literal("ai-workspace.entitlement-request.v1").optional(),
  appId: z.string().min(1).default(OPS_APP_ID),
  owner: z
    .object({
      ownerKey: z.string().min(1).optional(),
      userId: z.string().min(1).optional(),
      email: z.string().email().optional(),
      role: z.string().min(1).optional(),
      groups: z.array(z.string().min(1)).optional(),
    })
    .default({}),
  activeContext: z.record(z.string(), z.unknown()).optional(),
  runContext: z.record(z.string(), z.unknown()).optional(),
  requestedAt: z.string().datetime().optional(),
});

export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRouteDeps): Promise<void> {
  app.get("/api/v1/meta/capabilities", async () => ({
    ok: true,
    presets: {
      task_author: taskAuthorPresetScopes,
      reporter: reporterPresetScopes,
    },
    allowed_scopes: allowedAgentScopes,
    denied_mvp_capabilities: deniedMvpCapabilities,
    mcp_tools: mcpToolDefinitions,
  }));

  app.get("/api/v1/agent-session", async (request) => {
    const session = await authenticateAgentSession(request.headers.authorization, deps);

    return {
      ok: true,
      agent_session: serializeAgentSession(session),
    };
  });

  app.get("/api/v1/agent-session/setup", async (request) => {
    const session = await authenticateAgentSession(request.headers.authorization, deps);

    return {
      ok: true,
      setup: buildSetupPacket(session, deps.publicUrl),
    };
  });

  app.post("/api/v1/setup-codes/redeem", async (request, reply) => {
    const repository = requireRepository(deps);
    const body = redeemSetupCodeBodySchema.parse(request.body ?? {});
    const token = generateAgentToken();
    const ontologyToken = generateOntologyAgentToken();
    const result = await repository.redeemSetupCode(body.setup_code, token, ontologyToken);

    if (result.status !== "redeemed") {
      return reply.status(400).send({
        ok: false,
        error: "setup_code_not_redeemable",
        status: result.status,
        message: "Setup code is invalid, expired, used, or revoked.",
      });
    }

    return {
      ok: true,
      token,
      token_record: serializeToken(result.token),
      ops: {
        serverName: CODEX_MCP_SERVER_NAME,
        mcpUrl: new URL("/mcp", deps.publicUrl).toString(),
        token,
      },
      ontology: {
        serverName: "nodedc_ontology",
        mcpUrl: new URL("/ontology-mcp", deps.publicUrl).toString(),
        token: ontologyToken,
        tokenRecord: serializeToken(result.ontologyToken),
        readOnly: true,
      },
      agent: serializeAgent(result.agent),
      setup_code: serializeSetupCode(result.setupCode),
      setup: buildSetupPacketForAgent(result.agent, result.grants, deps.publicUrl),
    };
  });

  app.post("/api/internal/v1/ai-workspace/entitlements", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const body = aiWorkspaceEntitlementBodySchema.parse(request.body ?? {});
    const requestedAppId = body.appId.trim().toLowerCase();

    if (requestedAppId !== OPS_APP_ID) {
      return reply.status(400).send({
        ok: false,
        error: "unsupported_app_id",
        message: `Agent Gateway can issue only ${OPS_APP_ID} entitlements.`,
      });
    }

    const ownerUserId = normalizeText(body.owner.userId ?? body.owner.ownerKey);
    const ownerEmail = normalizeText(body.owner.email)?.toLowerCase();

    if (!ownerUserId && !ownerEmail) {
      return reply.status(400).send({
        ok: false,
        error: "owner_identity_required",
        message: "owner.userId, owner.ownerKey, or owner.email is required.",
      });
    }

    const requestedContext = extractRequestedGrantContext(body.runContext, body.activeContext);
    const agents = await repository.listAgentsByOwnerIdentity(ownerUserId ?? undefined, ownerEmail ?? undefined);
    const entitlementCandidate = await findEntitlementCandidate(repository, agents, requestedContext);

    if (!entitlementCandidate) {
      return {
        ok: true,
        appGrants: {
          [OPS_APP_ID]: buildEmptyOpsAppGrant(requestedContext, agents.length === 0 ? "agent_not_found" : "matching_grant_not_found"),
        },
      };
    }

    const token = generateAgentToken();
    const expiresAt = new Date(Date.now() + deps.aiWorkspaceRunTokenTtlSeconds * 1000).toISOString();
    const tokenRecord = await repository.createToken(entitlementCandidate.agent.id, {
      token,
      name: `AI Workspace run ${new Date().toISOString()}`,
      expiresAt,
      tokenGrants: buildRunTokenGrants(entitlementCandidate.matchingGrants, ownerUserId ?? ownerEmail ?? "ai-workspace"),
    });

    await repository.createAuditEvent(entitlementCandidate.agent.id, "ai_workspace.entitlement.issued", ownerUserId ?? ownerEmail ?? undefined, {
      appId: OPS_APP_ID,
      requestedContext,
      selectedGrantIds: entitlementCandidate.matchingGrants.map((grant) => grant.id),
      tokenId: tokenRecord.id,
      expiresAt,
      grantMode: "token-scoped-run-grants",
    });

    return {
      ok: true,
      appGrants: {
        [OPS_APP_ID]: buildOpsAppGrant({
          agent: entitlementCandidate.agent,
          allGrants: entitlementCandidate.allGrants,
          matchingGrants: entitlementCandidate.matchingGrants,
          requestedContext,
          token,
          tokenRecord,
          publicUrl: deps.publicUrl,
          ttlSeconds: deps.aiWorkspaceRunTokenTtlSeconds,
        }),
      },
    };
  });

  app.post("/api/internal/v1/ai-workspace/preflight", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const body = aiWorkspaceEntitlementBodySchema.parse(request.body ?? {});
    const requestedAppId = body.appId.trim().toLowerCase();

    if (requestedAppId !== OPS_APP_ID) {
      return reply.status(400).send({
        ok: false,
        error: "unsupported_app_id",
        message: `Agent Gateway can preflight only ${OPS_APP_ID} entitlements.`,
      });
    }

    const ownerUserId = normalizeText(body.owner.userId ?? body.owner.ownerKey);
    const ownerEmail = normalizeText(body.owner.email)?.toLowerCase();

    if (!ownerUserId && !ownerEmail) {
      return reply.status(400).send({
        ok: false,
        error: "owner_identity_required",
        message: "owner.userId, owner.ownerKey, or owner.email is required.",
      });
    }

    const requestedContext = extractRequestedGrantContext(body.runContext, body.activeContext);
    const agents = await repository.listAgentsByOwnerIdentity(ownerUserId ?? undefined, ownerEmail ?? undefined);
    const entitlementCandidate = await findEntitlementCandidate(repository, agents, requestedContext);

    return {
      ok: true,
      appId: OPS_APP_ID,
      status: entitlementCandidate ? "ready" : agents.length === 0 ? "agent_not_found" : "matching_grant_not_found",
      diagnostics: buildOpsPreflightDiagnostics({
        agents,
        entitlementCandidate,
        requestedContext,
        publicUrl: deps.publicUrl,
        ttlSeconds: deps.aiWorkspaceRunTokenTtlSeconds,
      }),
    };
  });

  app.post("/api/v1/agents", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const body = createAgentBodySchema.parse(request.body);
    const agent = await repository.createAgent({
      ownerUserId: body.owner_user_id,
      ownerEmail: body.owner_email,
      displayName: body.display_name,
      avatarUrl: body.avatar_url,
    });

    return reply.status(201).send({
      ok: true,
      agent: serializeAgent(agent),
    });
  });

  app.get("/api/v1/agents", async (request) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const query = listAgentsQuerySchema.parse(request.query);
    const agents = await repository.listAgents(query.owner_user_id);

    return {
      ok: true,
      agents: agents.map(serializeAgent),
    };
  });

  app.get("/api/v1/agents/:agentId", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId } = agentParamsSchema.parse(request.params);
    const agent = await repository.getAgent(agentId);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    return {
      ok: true,
      agent: serializeAgent(agent),
    };
  });

  app.post("/api/v1/agents/:agentId/revoke", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId } = agentParamsSchema.parse(request.params);
    const body = actorBodySchema.parse(request.body ?? {});
    const agent = await repository.revokeAgent(agentId, body.actor_user_id);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    return {
      ok: true,
      agent: serializeAgent(agent),
    };
  });

  app.post("/api/v1/agents/:agentId/grants", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId } = agentParamsSchema.parse(request.params);
    const body = upsertGrantBodySchema.parse(request.body);
    const agent = await repository.getAgent(agentId);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grant = await repository.upsertGrant(agentId, {
      workspaceSlug: body.workspace_slug,
      projectId: body.project_id,
      scopes: body.scopes,
      mode: body.mode,
      createdByUserId: body.created_by_user_id,
    });

    return reply.status(201).send({
      ok: true,
      grant: serializeGrant(grant),
    });
  });

  app.get("/api/v1/agents/:agentId/grants", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId } = agentParamsSchema.parse(request.params);
    const agent = await repository.getAgent(agentId);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grants = await repository.listGrants(agentId);
    return {
      ok: true,
      grants: grants.map(serializeGrant),
    };
  });

  app.post("/api/v1/agents/:agentId/grants/replace", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId } = agentParamsSchema.parse(request.params);
    const body = replaceWorkspaceGrantsBodySchema.parse(request.body);
    const agent = await repository.getAgent(agentId);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grants = await repository.replaceWorkspaceProjectGrants(agentId, {
      workspaceSlug: body.workspace_slug,
      projectIds: body.project_ids,
      scopes: body.scopes,
      mode: body.mode,
      actorUserId: body.created_by_user_id,
    });

    return reply.status(200).send({
      ok: true,
      grants: grants.map(serializeGrant),
    });
  });

  app.post("/api/v1/agents/:agentId/grants/replace-projects", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId } = agentParamsSchema.parse(request.params);
    const body = replaceProjectGrantsBodySchema.parse(request.body);
    const agent = await repository.getAgent(agentId);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grants = await repository.replaceProjectGrants(agentId, {
      grants: body.grants.map((grant) => ({
        workspaceSlug: grant.workspace_slug,
        projectId: grant.project_id,
      })),
      scopes: body.scopes,
      mode: body.mode,
      actorUserId: body.created_by_user_id,
    });

    return reply.status(200).send({
      ok: true,
      grants: grants.map(serializeGrant),
    });
  });

  app.post("/api/v1/agents/:agentId/tokens", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId } = agentParamsSchema.parse(request.params);
    const body = createTokenBodySchema.parse(request.body ?? {});
    const agent = await repository.getAgent(agentId);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    const token = generateAgentToken();
    const tokenRecord = await repository.createToken(agentId, {
      token,
      name: body.name,
      expiresAt: body.expires_at,
    });

    return reply.status(201).send({
      ok: true,
      token,
      token_record: serializeToken(tokenRecord),
    });
  });

  app.get("/api/v1/agents/:agentId/tokens", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId } = agentParamsSchema.parse(request.params);
    const agent = await repository.getAgent(agentId);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    const tokens = await repository.listTokens(agentId);
    return {
      ok: true,
      tokens: tokens.map(serializeToken),
    };
  });

  app.post("/api/v1/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { agentId, tokenId } = tokenParamsSchema.parse(request.params);
    const body = actorBodySchema.parse(request.body ?? {});
    const tokenRecord = await repository.revokeToken(agentId, tokenId, body.actor_user_id);

    if (!tokenRecord) {
      return sendNotFound(reply, "token_not_found");
    }

    return {
      ok: true,
      token_record: serializeToken(tokenRecord),
    };
  });

  app.get("/api/internal/v1/owners/:ownerUserId/agents", async (request) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId } = ownerParamsSchema.parse(request.params);
    const agents = await repository.listAgents(ownerUserId);

    return {
      ok: true,
      agents: agents.map(serializeAgent),
    };
  });

  app.post("/api/internal/v1/owners/:ownerUserId/agents", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId } = ownerParamsSchema.parse(request.params);
    const body = createOwnerAgentBodySchema.parse(request.body);
    const agent = await repository.createAgent({
      ownerUserId,
      ownerEmail: body.owner_email,
      displayName: body.display_name,
      avatarUrl: body.avatar_url,
    });

    return reply.status(201).send({
      ok: true,
      agent: serializeAgent(agent),
    });
  });

  app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const [grants, tokens] = await Promise.all([repository.listGrants(agentId), repository.listTokens(agentId)]);
    return {
      ok: true,
      agent: serializeAgent(agent),
      grants: grants.map(serializeGrant),
      tokens: tokens.map(serializeToken),
    };
  });

  app.patch("/api/internal/v1/owners/:ownerUserId/agents/:agentId", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const body = updateOwnerAgentBodySchema.parse(request.body ?? {});
    const existingAgent = await repository.getAgent(agentId);

    if (!existingAgent || existingAgent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const agent = await repository.updateAgentProfile(agentId, {
      displayName: body.display_name,
      avatarUrl: body.avatar_url,
      actorUserId: body.actor_user_id ?? ownerUserId,
    });

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    return {
      ok: true,
      agent: serializeAgent(agent),
    };
  });

  app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/revoke", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const body = actorBodySchema.parse(request.body ?? {});
    const existingAgent = await repository.getAgent(agentId);

    if (!existingAgent || existingAgent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const agent = await repository.revokeAgent(agentId, body.actor_user_id ?? ownerUserId);

    if (!agent) {
      return sendNotFound(reply, "agent_not_found");
    }

    return {
      ok: true,
      agent: serializeAgent(agent),
    };
  });

  app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grants = await repository.listGrants(agentId);
    return {
      ok: true,
      grants: grants.map(serializeGrant),
    };
  });

  app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const body = upsertGrantBodySchema.omit({ created_by_user_id: true }).parse(request.body);
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grant = await repository.upsertGrant(agentId, {
      workspaceSlug: body.workspace_slug,
      projectId: body.project_id,
      scopes: body.scopes,
      mode: body.mode,
      createdByUserId: ownerUserId,
    });

    return reply.status(201).send({
      ok: true,
      grant: serializeGrant(grant),
    });
  });

  app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants/replace", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const body = replaceWorkspaceGrantsBodySchema.omit({ created_by_user_id: true }).parse(request.body);
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grants = await repository.replaceWorkspaceProjectGrants(agentId, {
      workspaceSlug: body.workspace_slug,
      projectIds: body.project_ids,
      scopes: body.scopes,
      mode: body.mode,
      actorUserId: ownerUserId,
    });

    return reply.status(200).send({
      ok: true,
      grants: grants.map(serializeGrant),
    });
  });

  app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants/replace-projects", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const body = replaceProjectGrantsBodySchema.omit({ created_by_user_id: true }).parse(request.body);
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grants = await repository.replaceProjectGrants(agentId, {
      grants: body.grants.map((grant) => ({
        workspaceSlug: grant.workspace_slug,
        projectId: grant.project_id,
      })),
      scopes: body.scopes,
      mode: body.mode,
      actorUserId: ownerUserId,
    });

    return reply.status(200).send({
      ok: true,
      grants: grants.map(serializeGrant),
    });
  });

  app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const tokens = await repository.listTokens(agentId);
    return {
      ok: true,
      tokens: tokens.map(serializeToken),
    };
  });

  app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const body = createTokenBodySchema.parse(request.body ?? {});
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const token = generateAgentToken();
    const tokenRecord = await repository.createToken(agentId, {
      token,
      name: body.name,
      expiresAt: body.expires_at,
    });
    const grants = await repository.listGrants(agentId);

    return reply.status(201).send({
      ok: true,
      token,
      token_record: serializeToken(tokenRecord),
      setup: buildSetupPacketForAgent(agent, grants, deps.publicUrl),
    });
  });

  app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/setup-codes", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const body = createSetupCodeBodySchema.parse(request.body ?? {});
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const setupCode = generateAgentSetupCode();
    const expiresAt = new Date(Date.now() + body.expires_in_seconds * 1000).toISOString();
    const setupCodeRecord = await repository.createSetupCode(agentId, {
      code: setupCode,
      createdByUserId: ownerUserId,
      expiresAt,
      tokenName: body.token_name,
    });

    return reply.status(201).send({
      ok: true,
      setup_code: setupCode,
      setup_code_record: serializeSetupCode(setupCodeRecord),
      install: buildCodexInstallerPacket(setupCode, deps),
    });
  });

  app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId, tokenId } = ownerTokenParamsSchema.parse(request.params);
    const body = actorBodySchema.parse(request.body ?? {});
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const tokenRecord = await repository.revokeToken(agentId, tokenId, body.actor_user_id ?? ownerUserId);

    if (!tokenRecord) {
      return sendNotFound(reply, "token_not_found");
    }

    return {
      ok: true,
      token_record: serializeToken(tokenRecord),
    };
  });

  app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/setup", async (request, reply) => {
    requireLifecycleAccess(request, deps);
    const repository = requireRepository(deps);
    const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params);
    const agent = await repository.getAgent(agentId);

    if (!agent || agent.ownerUserId !== ownerUserId) {
      return sendNotFound(reply, "agent_not_found");
    }

    const grants = await repository.listGrants(agentId);
    return {
      ok: true,
      setup: buildSetupPacketForAgent(agent, grants, deps.publicUrl),
    };
  });
}

function requireRepository(deps: AgentRouteDeps): AgentsRepository {
  if (!deps.agentsRepository) {
    throw new DatabaseNotConfiguredError();
  }

  return deps.agentsRepository;
}

function requireLifecycleAccess(request: FastifyRequest, deps: AgentRouteDeps): void {
  requireInternalAccess(request.headers.authorization, deps.internalAccessToken);
}

function sendNotFound(reply: FastifyReply, error: string): FastifyReply {
  return reply.status(404).send({
    ok: false,
    error,
  });
}

type RequestedGrantContext = {
  workspaceSlug: string | null;
  projectId: string | null;
};

type EntitlementCandidate = {
  agent: AgentRecord;
  allGrants: AgentGrantRecord[];
  matchingGrants: AgentGrantRecord[];
};

type BuildOpsAppGrantInput = EntitlementCandidate & {
  requestedContext: RequestedGrantContext;
  token: string;
  tokenRecord: AgentTokenRecord;
  publicUrl: string;
  ttlSeconds: number;
};

type BuildOpsPreflightDiagnosticsInput = {
  agents: AgentRecord[];
  entitlementCandidate: EntitlementCandidate | null;
  requestedContext: RequestedGrantContext;
  publicUrl: string;
  ttlSeconds: number;
};

async function findEntitlementCandidate(
  repository: AgentsRepository,
  agents: AgentRecord[],
  requestedContext: RequestedGrantContext
): Promise<EntitlementCandidate | null> {
  for (const agent of agents) {
    if (agent.status !== "active") {
      continue;
    }

    const allGrants = await repository.listGrants(agent.id);
    const matchingGrants = filterMatchingGrants(allGrants, requestedContext);
    if (matchingGrants.length > 0) {
      return {
        agent,
        allGrants,
        matchingGrants,
      };
    }
  }

  return null;
}

function buildOpsAppGrant(input: BuildOpsAppGrantInput): Record<string, unknown> {
  const endpoint = new URL("/mcp", input.publicUrl).toString();

  return {
    appId: OPS_APP_ID,
    appTitle: OPS_APP_TITLE,
    status: "granted",
    source: "ops-agent-gateway",
    grantMode: "token-scoped-run-grants",
    tokenExpiresAt: input.tokenRecord.expiresAt,
    tokenTtlSeconds: input.ttlSeconds,
    scopes: uniqueStrings(input.matchingGrants.flatMap((grant) => grant.scopes)),
    context: {
      requestedWorkspaceSlug: input.requestedContext.workspaceSlug,
      requestedProjectId: input.requestedContext.projectId,
      agent: serializeAgent(input.agent),
      matchingGrants: input.matchingGrants.map(serializeGrant),
      allGrantIds: input.allGrants.map((grant) => grant.id),
    },
    mcpServers: [
      {
        appId: OPS_APP_ID,
        appTitle: OPS_APP_TITLE,
        serverName: OPS_MCP_SERVER_NAME,
        name: OPS_MCP_SERVER_NAME,
        transport: "streamable_http_json_rpc",
        url: endpoint,
        required: false,
        startupTimeoutSec: 20,
        toolTimeoutSec: 60,
        httpHeaders: {
          Authorization: `Bearer ${input.token}`,
          Accept: "application/json",
          "MCP-Protocol-Version": "2025-06-18",
        },
      },
    ],
  };
}

function buildOpsPreflightDiagnostics(input: BuildOpsPreflightDiagnosticsInput): Record<string, unknown> {
  const endpoint = new URL("/mcp", input.publicUrl).toString();
  const matchingGrants = input.entitlementCandidate?.matchingGrants ?? [];

  return {
    source: "ops-agent-gateway",
    requestedWorkspaceSlug: input.requestedContext.workspaceSlug,
    requestedProjectId: input.requestedContext.projectId,
    targetContextPresent: Boolean(input.requestedContext.workspaceSlug || input.requestedContext.projectId),
    activeAgentCount: input.agents.filter((agent) => agent.status === "active").length,
    selectedAgent: input.entitlementCandidate ? serializeAgent(input.entitlementCandidate.agent) : null,
    matchingGrantCount: matchingGrants.length,
    matchingGrants: matchingGrants.map(serializeGrant),
    scopes: uniqueStrings(matchingGrants.flatMap((grant) => grant.scopes)),
    grantMode: "token-scoped-run-grants",
    tokenGrantScope: "token",
    tokenTtlSeconds: input.ttlSeconds,
    wouldIssueToken: Boolean(input.entitlementCandidate),
    mcpServer: {
      serverName: OPS_MCP_SERVER_NAME,
      url: endpoint,
      required: false,
      startupTimeoutSec: 20,
      toolTimeoutSec: 60,
    },
  };
}

function buildEmptyOpsAppGrant(requestedContext: RequestedGrantContext, status: string): Record<string, unknown> {
  return {
    appId: OPS_APP_ID,
    appTitle: OPS_APP_TITLE,
    status,
    source: "ops-agent-gateway",
    grantMode: "token-scoped-run-grants",
    scopes: [],
    context: {
      requestedWorkspaceSlug: requestedContext.workspaceSlug,
      requestedProjectId: requestedContext.projectId,
    },
    mcpServers: [],
  };
}

function filterMatchingGrants(grants: AgentGrantRecord[], requestedContext: RequestedGrantContext): AgentGrantRecord[] {
  if (!requestedContext.workspaceSlug && !requestedContext.projectId) {
    return [];
  }

  return grants.filter((grant) => {
    if (requestedContext.workspaceSlug && grant.workspaceSlug !== requestedContext.workspaceSlug) {
      return false;
    }

    if (requestedContext.projectId) {
      if (grant.projectId === requestedContext.projectId) {
        return true;
      }

      return Boolean(requestedContext.workspaceSlug && grant.projectId === null);
    }

    return grant.projectId === null;
  });
}

function buildRunTokenGrants(grants: AgentGrantRecord[], createdByUserId: string): CreateTokenGrantInput[] {
  return grants.map((grant) => ({
    workspaceSlug: grant.workspaceSlug,
    projectId: grant.projectId,
    scopes: grant.scopes,
    mode: grant.mode,
    createdByUserId,
    sourceGrantId: grant.id,
  }));
}

function extractRequestedGrantContext(runContext?: Record<string, unknown>, activeContext?: Record<string, unknown>): RequestedGrantContext {
  const contextSources = [
    getRecordValue(runContext, "ops"),
    getRecordValue(runContext, "opsContext"),
    getRecordValue(runContext, "activeContext"),
    runContext,
    getRecordValue(activeContext, "ops"),
    getRecordValue(activeContext, "opsContext"),
    activeContext,
  ];

  return {
    workspaceSlug: firstTextValue(contextSources, ["workspaceSlug", "workspace_slug", "opsWorkspaceSlug", "ops_workspace_slug"]),
    projectId: firstTextValue(contextSources, ["projectId", "project_id", "opsProjectId", "ops_project_id"]),
  };
}

function firstTextValue(sources: Array<Record<string, unknown> | undefined>, keys: string[]): string | null {
  for (const source of sources) {
    if (!source) {
      continue;
    }

    for (const key of keys) {
      const value = normalizeText(source[key]);
      if (value) {
        return value;
      }
    }
  }

  return null;
}

function getRecordValue(source: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
  const value = source?.[key];
  return isRecord(value) ? value : undefined;
}

function normalizeText(value: unknown): string | null {
  return typeof value === "string" && value.trim() ? value.trim() : null;
}

function uniqueStrings(values: string[]): string[] {
  return [...new Set(values)];
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

async function authenticateAgentSession(authorization: string | undefined, deps: AgentRouteDeps): Promise<AgentSessionRecord> {
  const repository = requireRepository(deps);
  const token = parseBearerToken(authorization);
  const session = await repository.findActiveSessionByToken(token);

  if (!session) {
    throw new UnauthorizedError("Agent token is inactive, expired, or revoked.");
  }

  return session;
}

function serializeAgent(agent: AgentRecord): Record<string, unknown> {
  return {
    id: agent.id,
    owner_user_id: agent.ownerUserId,
    owner_email: agent.ownerEmail,
    display_name: agent.displayName,
    avatar_url: agent.avatarUrl,
    status: agent.status,
    created_at: agent.createdAt,
    updated_at: agent.updatedAt,
  };
}

function serializeGrant(grant: AgentGrantRecord): Record<string, unknown> {
  return {
    id: grant.id,
    agent_id: grant.agentId,
    workspace_slug: grant.workspaceSlug,
    project_id: grant.projectId,
    scopes: grant.scopes,
    mode: grant.mode,
    created_by_user_id: grant.createdByUserId,
    created_at: grant.createdAt,
    updated_at: grant.updatedAt,
  };
}

function serializeToken(token: AgentTokenRecord): Record<string, unknown> {
  return {
    id: token.id,
    agent_id: token.agentId,
    name: token.name,
    status: token.status,
    purpose: token.purpose,
    grant_scope: token.grantScope,
    token_suffix: token.tokenSuffix,
    expires_at: token.expiresAt,
    last_used_at: token.lastUsedAt,
    created_at: token.createdAt,
  };
}

function serializeSetupCode(setupCode: AgentSetupCodeRecord): Record<string, unknown> {
  return {
    id: setupCode.id,
    agent_id: setupCode.agentId,
    code_suffix: setupCode.codeSuffix,
    status: setupCode.status,
    token_name: setupCode.tokenName,
    created_by_user_id: setupCode.createdByUserId,
    expires_at: setupCode.expiresAt,
    used_at: setupCode.usedAt,
    used_token_id: setupCode.usedTokenId,
    used_ontology_token_id: setupCode.usedOntologyTokenId,
    created_at: setupCode.createdAt,
  };
}

function buildCodexInstallerPacket(setupCode: string, deps: AgentRouteDeps): Record<string, unknown> {
  const gatewayUrl = new URL(deps.publicUrl).origin;
  const packageUrl = new URL("/install/nodedc-ops-codex-setup.tgz", gatewayUrl);
  packageUrl.searchParams.set("v", CODEX_NPM_PACKAGE_VERSION);
  packageUrl.searchParams.set("s", setupCode.slice(-8));
  const packageUrlString = packageUrl.toString();
  const installerUrl = new URL("/install/codex.py", gatewayUrl).toString();
  const registryCommand = [
    "npx",
    "--yes",
    shellWord(deps.codexNpmSpec),
    "setup",
    shellWord(setupCode),
    ...(gatewayUrl === DEFAULT_OPS_AGENT_GATEWAY ? [] : ["--gateway", shellWord(gatewayUrl)]),
  ].join(" ");
  const fallbackCommand = [
    "npm",
    "exec",
    "--yes",
    `--package=${shellWord(packageUrlString)}`,
    "--",
    "ops-codex",
    "setup",
    shellWord(setupCode),
    "--gateway",
    shellWord(gatewayUrl),
  ].join(" ");
  const command = deps.codexInstallChannel === "registry" ? registryCommand : fallbackCommand;

  return {
    platform: "macos_linux",
    installer_kind: deps.codexInstallChannel === "registry" ? "npm_registry" : "npm_tarball",
    installer_url: packageUrlString,
    npm_spec: deps.codexNpmSpec,
    package_url: packageUrlString,
    legacy_python_installer_url: installerUrl,
    registry_command: registryCommand,
    fallback_command: fallbackCommand,
    mcp_server_name: CODEX_MCP_SERVER_NAME,
    skill_name: "ops-context",
    command,
  };
}

function shellWord(value: string): string {
  if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
    return value;
  }

  return shellSingleQuote(value);
}

function shellSingleQuote(value: string): string {
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
}

function serializeAgentSession(session: AgentSessionRecord): Record<string, unknown> {
  return {
    agent: serializeAgent(session.agent),
    token: serializeToken(session.token),
    grant_source: session.grantSource,
    grants: session.grants.map(serializeGrant),
    effective_scopes: [...new Set(session.grants.flatMap((grant) => grant.scopes))],
    modes: [...new Set(session.grants.map((grant) => grant.mode))],
  };
}

function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Record<string, unknown> {
  const endpoint = new URL("/mcp", publicUrl).toString();
  const ontologyEndpoint = new URL("/ontology-mcp", publicUrl).toString();
  const tokenPlaceholder = "<agent-token>";
  const ontologyTokenPlaceholder = "<ontology-token>";

  return {
    mcp_server: {
      name: CODEX_MCP_SERVER_NAME,
      transport: "streamable_http_json_rpc",
      url: endpoint,
      headers: {
        Authorization: `Bearer ${tokenPlaceholder}`,
        Accept: "application/json",
        "MCP-Protocol-Version": "2025-06-18",
      },
    },
    ontology_mcp_server: {
      name: "nodedc_ontology",
      transport: "streamable_http_json_rpc",
      url: ontologyEndpoint,
      read_only: true,
      headers: {
        Authorization: `Bearer ${ontologyTokenPlaceholder}`,
        Accept: "application/json",
        "MCP-Protocol-Version": "2025-06-18",
      },
    },
    codex_config_template: {
      mcp_servers: {
        [CODEX_MCP_SERVER_NAME]: {
          url: endpoint,
          enabled: true,
          required: false,
          startup_timeout_sec: 20,
          tool_timeout_sec: 60,
          headers: {
            Authorization: `Bearer ${tokenPlaceholder}`,
            Accept: "application/json",
            "MCP-Protocol-Version": "2025-06-18",
          },
        },
        nodedc_ontology: {
          url: ontologyEndpoint,
          enabled: true,
          required: false,
          startup_timeout_sec: 20,
          tool_timeout_sec: 60,
          headers: {
            Authorization: `Bearer ${ontologyTokenPlaceholder}`,
            Accept: "application/json",
            "MCP-Protocol-Version": "2025-06-18",
          },
        },
      },
    },
    available_tools: getToolsForSession(session).map((tool) => ({
      name: tool.name,
      title: tool.title,
      description: tool.description,
      required_scopes: tool.requiredScopes,
    })),
    agents_md: buildAgentsMd(session, endpoint),
  };
}

function buildSetupPacketForAgent(agent: AgentRecord, grants: AgentGrantRecord[], publicUrl: string): Record<string, unknown> {
  return buildSetupPacket(
    {
        agent,
        grants,
        grantSource: "agent",
        token: {
          id: "00000000-0000-0000-0000-000000000000",
          agentId: agent.id,
          name: "Agent token placeholder",
          status: "active",
          purpose: "ops",
          grantScope: "agent",
          tokenSuffix: null,
          expiresAt: null,
        lastUsedAt: null,
        createdAt: new Date(0).toISOString(),
      },
    },
    publicUrl
  );
}

function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string {
  const grants = session.grants
    .map((grant) => `- workspace=${grant.workspaceSlug}; project=${grant.projectId ?? "*"}; mode=${grant.mode}; scopes=${grant.scopes.join(",")}`)
    .join("\n");
  const tools = getToolsForSession(session)
    .map((tool) => `- ${tool.name}: ${tool.description}`)
    .join("\n");

  return [
    "# NODE.DC Ops Agent Rules",
    "",
    `MCP endpoint: ${endpoint}`,
    "",
    "## Startup",
    "",
    "- Call `tasker_get_agent_instructions` before creating or changing Tasker cards.",
    "- Call `tasker_list_projects` and `tasker_get_project_context` before writing into a project.",
    "- Keep Tasker as the source of truth for project cards, checkers, status, labels, comments, and assignments.",
    "- Use the separately configured read-only `nodedc_ontology` MCP before inventing semantic ids, types, aliases, relations, or routing assumptions.",
    "",
    "## Write Safety",
    "",
    "- Every write tool call must include a unique `idempotency_key`.",
    "- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.",
    "- Create projects only through `tasker_create_project` when the effective workspace grant includes `project:create`.",
    "- Attach files only through `tasker_attach_file`; never use arbitrary server paths or remote fetch URLs.",
    "- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.",
    "- Only assign existing project members returned by project context.",
    "- If a needed label is missing, call `tasker_ensure_labels` first and then use returned ids with `tasker_set_issue_labels`.",
    "",
    "## Card Writing",
    "",
    "- Keep card titles concise and operational.",
    "- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.",
    "- Put each structured block heading into the block `title` field. Do not put markdown headings like `## Status` inside block body.",
    "- Do not put headings inside block body. Wrong: body starts with `## Текущая архитектура`. Correct: `title: \"Текущая архитектура\"`, body contains only content.",
    "- Put short verifiable work items into checker blocks with explicit titles.",
    "- After code work, update the related card with factual files touched and validation performed.",
    "",
    "## Labels",
    "",
    "- Before assigning a new marker/label, check project context labels.",
    "- If the label does not exist, call `tasker_ensure_labels` with the granted `project_id`.",
    "- Use only label ids returned by project context or `tasker_ensure_labels` when calling `tasker_set_issue_labels`.",
    "",
    "## Effective Grants",
    "",
    grants || "- No grants available.",
    "",
    "## Available Tools",
    "",
    tools || "- No tools available for current grants.",
    "",
  ].join("\n");
}
