/**
 * Copyright (c) 2023-present Plane Software, Inc. and contributors
 * SPDX-License-Identifier: AGPL-3.0-only
 * See the LICENSE file for details.
 */

import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Bot, Check, ChevronDown, Copy, FolderKanban, KeyRound, Route, ShieldCheck, Terminal } from "lucide-react";
import useSWR from "swr";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { getFileURL } from "@plane/utils";
// components
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// services
import {
  WorkspaceCodexAgentService,
  type TCodexAgent,
  type TCodexAgentGrant,
  type TCodexAgentGrantableWorkspace,
  type TCodexAgentSetupCode,
  type TCodexAgentSetupPacket,
  type TCodexAgentToken,
} from "@/services/workspace-codex-agent.service";
import { WorkspaceService } from "@/services/workspace.service";

const TASK_AUTHOR_SCOPES = [
  "workspace:read",
  "project:read",
  "project:create",
  "project:member:add_existing",
  "issue:read",
  "issue:create",
  "issue:update",
  "issue:move",
  "issue:comment",
  "issue:label",
  "issue:assign",
  "issue:attachment:write",
  "issue:structured_blocks:write",
];

const AGENT_AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
const MAX_AGENT_AVATAR_SOURCE_BYTES = 50 * 1024 * 1024;
const AGENT_AVATAR_RENDER_SIZE = 512;
const AGENT_AVATAR_OUTPUT_QUALITY = 0.86;
const CODEX_AGENT_TOKEN_PREFIX = "ndcag_";
const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent";
const DEFAULT_OPS_AGENT_MCP_ENDPOINT = "https://ops-agents.nodedc.ru/mcp";

const codexAgentService = new WorkspaceCodexAgentService();
const workspaceService = new WorkspaceService();

type TProps = {
  showHeading?: boolean;
  workspaceSlug: string;
};

type TAgentSetupCard = {
  agent: TCodexAgent;
  grants: TCodexAgentGrant[];
  installCommand?: string;
  setup?: TCodexAgentSetupPacket;
  setupCode?: TCodexAgentSetupCode;
  tokens: TCodexAgentToken[];
};

type TProjectAccessOption = {
  id: string;
  identifier?: string | null;
  key: string;
  name: string;
  workspaceName: string;
  workspaceSlug: string;
};

export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) {
  const { showHeading = true, workspaceSlug } = props;
  const createAvatarInputRef = useRef<HTMLInputElement | null>(null);
  const agentAvatarInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
  const [isCreatingAgent, setIsCreatingAgent] = useState(false);
  const [newAgentName, setNewAgentName] = useState("Local Codex");
  const [newAgentAvatarUrl, setNewAgentAvatarUrl] = useState<string | null>(null);
  const [selectedProjectKey, setSelectedProjectKey] = useState("");
  const [agentDraftNames, setAgentDraftNames] = useState<Record<string, string>>({});
  const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
  const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
  const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
  const [creatingSetupCodeAgentIds, setCreatingSetupCodeAgentIds] = useState<Record<string, boolean>>({});
  const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
  const [openProjectAccessAgentId, setOpenProjectAccessAgentId] = useState<string | null>(null);
  const [projectGrantDrafts, setProjectGrantDrafts] = useState<Record<string, string[]>>({});
  const [savingProjectGrantAgentIds, setSavingProjectGrantAgentIds] = useState<Record<string, boolean>>({});
  const { currentWorkspace } = useWorkspace();
  const { data: nodedcWorkspacePolicy, isLoading } = useSWR(
    workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null,
    () => workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
  );
  const isCodexAgentEntitled = nodedcWorkspacePolicy?.service_modules?.codex_agents === true;
  const {
    data: codexAgentsPayload,
    error: codexAgentsError,
    isLoading: areAgentsLoading,
    mutate: mutateCodexAgents,
  } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_AGENTS_${workspaceSlug}` : null, () =>
    codexAgentService.listAgents(workspaceSlug)
  );
  const { data: projectAccessPayload } = useSWR(
    isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECT_ACCESS_${workspaceSlug}` : null,
    () => codexAgentService.listProjectAccess(workspaceSlug)
  );
  const activeAgents = useMemo(
    () => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"),
    [codexAgentsPayload?.agents]
  );
  const activeAgentIds = useMemo(() => activeAgents.map((agent) => agent.id).join(","), [activeAgents]);
  const {
    data: persistedSetupCards,
    isLoading: areSetupCardsLoading,
    mutate: mutateSetupCards,
  } = useSWR(
    isCodexAgentEntitled && activeAgentIds
      ? `CODEX_AGENT_API_AGENT_SETUP_CARDS_${workspaceSlug}_${activeAgentIds}`
      : null,
    async () =>
      Promise.all(
        activeAgents.map(async (agent) => {
          const [tokensPayload, setupPayload, grantsPayload] = await Promise.all([
            codexAgentService.listTokens(workspaceSlug, agent.id),
            codexAgentService.getSetup(workspaceSlug, agent.id),
            codexAgentService.listGrants(workspaceSlug, agent.id),
          ]);

          return {
            agent,
            grants: grantsPayload.grants,
            setup: setupPayload.setup,
            tokens: tokensPayload.tokens.filter((token) => token.status === "active"),
          };
        })
      )
  );
  const workspaceProjectGroups = useMemo(
    () => projectAccessPayload?.workspaces ?? [],
    [projectAccessPayload?.workspaces]
  );
  const projectOptions = useMemo(() => flattenProjectAccessOptions(workspaceProjectGroups), [workspaceProjectGroups]);
  const effectiveSelectedProjectKey = selectedProjectKey || projectOptions[0]?.key || "";
  const setupCards = useMemo(
    () => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards),
    [createdSetupCards, persistedSetupCards]
  );
  const connectionGuideMcpEndpoint = getMcpEndpoint(setupCards.find((card) => card.setup)?.setup);
  const connectionGuideConfigSnippet = buildCodexConfigSnippet(connectionGuideMcpEndpoint);

  const handleCopy = async (value: string, label: string) => {
    await navigator.clipboard.writeText(value);
    setToast({
      type: TOAST_TYPE.SUCCESS,
      title: `${label} скопирован`,
      message: "Вставьте скопированный фрагмент в нужное место локальной настройки Codex.",
    });
  };

  const handleCreateAvatarChange = async (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (!file) return;

    try {
      setNewAgentAvatarUrl(await readAvatarDataUrl(file));
    } catch (error: any) {
      showAvatarError(error?.message);
    }
  };

  const handleAgentAvatarChange = async (agentId: string, event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = "";
    if (!file) return;

    try {
      const avatarUrl = await readAvatarDataUrl(file);
      setUpdatingAgentIds((current) => ({ ...current, [agentId]: true }));
      const response = await codexAgentService.updateAgent(workspaceSlug, agentId, { avatar_url: avatarUrl });
      setCreatedSetupCards((currentCards) =>
        currentCards.map((card) => (card.agent.id === agentId ? { ...card, agent: response.agent } : card))
      );
      await mutateCodexAgents();
      await mutateSetupCards();
    } catch (error: any) {
      setToast({
        type: TOAST_TYPE.ERROR,
        title: "Не удалось обновить аватар агента",
        message: error?.message ?? error?.error ?? "Проверьте формат и размер изображения.",
      });
    } finally {
      setUpdatingAgentIds((current) => ({ ...current, [agentId]: false }));
    }
  };

  const handleCreateAgent = async () => {
    const displayName = newAgentName.trim();
    const initialGrant = parseProjectGrantKey(effectiveSelectedProjectKey);
    if (!displayName || !initialGrant) return;

    setIsCreatingAgent(true);
    try {
      const createResponse = await codexAgentService.createAgent(workspaceSlug, {
        display_name: displayName,
        avatar_url: newAgentAvatarUrl,
      });
      const grantsResponse = await codexAgentService.replaceProjectGrantsAcrossWorkspaces(
        workspaceSlug,
        createResponse.agent.id,
        {
          grants: [
            {
              workspace_slug: initialGrant.workspaceSlug,
              project_id: initialGrant.projectId,
            },
          ],
          scopes: TASK_AUTHOR_SCOPES,
          mode: "voluntary",
        }
      );
      const [setupResponse, setupCodeResponse] = await Promise.all([
        codexAgentService.getSetup(workspaceSlug, createResponse.agent.id),
        codexAgentService.createSetupCode(workspaceSlug, createResponse.agent.id, {
          expires_in_seconds: 600,
          token_name: `${displayName} local token`,
        }),
      ]);
      setCreatedSetupCards((currentCards) =>
        upsertSetupCardSetupCode(
          currentCards,
          createResponse.agent,
          setupCodeResponse.setup_code_record,
          setupCodeResponse.install.command,
          setupResponse.setup,
          grantsResponse.grants
        )
      );
      await mutateCodexAgents();
      await mutateSetupCards();
      setToast({
        type: TOAST_TYPE.SUCCESS,
        title: "Codex agent создан",
        message:
          "Скопируйте terminal-команду в локальный терминал. Setup-код действует 10 минут и используется один раз.",
      });
    } catch (error: any) {
      setToast({
        type: TOAST_TYPE.ERROR,
        title: "Не удалось создать Codex agent",
        message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный project.",
      });
    } finally {
      setIsCreatingAgent(false);
    }
  };

  const handleCreateSetupCode = async (agent: TCodexAgent) => {
    setCreatingSetupCodeAgentIds((current) => ({ ...current, [agent.id]: true }));
    try {
      const setupCodeResponse = await codexAgentService.createSetupCode(workspaceSlug, agent.id, {
        expires_in_seconds: 600,
        token_name: `${agent.display_name} local token`,
      });
      const currentCard = setupCards.find((card) => card.agent.id === agent.id);
      setCreatedSetupCards((currentCards) =>
        upsertSetupCardSetupCode(
          currentCards,
          agent,
          setupCodeResponse.setup_code_record,
          setupCodeResponse.install.command,
          currentCard?.setup,
          currentCard?.grants ?? []
        )
      );
      setToast({
        type: TOAST_TYPE.SUCCESS,
        title: "Terminal-команда выпущена",
        message: "Setup-код действует 10 минут и используется один раз.",
      });
    } catch (error: any) {
      setToast({
        type: TOAST_TYPE.ERROR,
        title: "Не удалось выпустить terminal-команду",
        message: error?.message ?? error?.error ?? "Проверьте Gateway и права workspace.",
      });
    } finally {
      setCreatingSetupCodeAgentIds((current) => ({ ...current, [agent.id]: false }));
    }
  };

  const handleCreateToken = async (agent: TCodexAgent) => {
    setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: true }));
    try {
      const tokenResponse = await codexAgentService.createToken(workspaceSlug, agent.id, {
        name: `${agent.display_name} local token`,
      });
      setRevealedTokens((currentTokens) => ({
        ...currentTokens,
        [tokenResponse.token_record.id]: tokenResponse.token,
      }));
      const currentGrants = setupCards.find((card) => card.agent.id === agent.id)?.grants ?? [];
      setCreatedSetupCards((currentCards) =>
        upsertSetupCardToken(currentCards, agent, tokenResponse.token_record, tokenResponse.setup, currentGrants)
      );
      await mutateSetupCards();
      setToast({
        type: TOAST_TYPE.SUCCESS,
        title: "Новый токен выпущен",
        message: "Скопируйте токен сейчас. После перезахода backend вернет только masked suffix.",
      });
    } catch (error: any) {
      setToast({
        type: TOAST_TYPE.ERROR,
        title: "Не удалось выпустить token",
        message: error?.message ?? error?.error ?? "Проверьте Gateway и права workspace.",
      });
    } finally {
      setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: false }));
    }
  };

  const handleToggleProjectGrant = (agentId: string, currentGrantKeys: string[], grantKey: string) => {
    setProjectGrantDrafts((currentDrafts) => {
      const currentDraftGrantKeys = currentDrafts[agentId] ?? currentGrantKeys;
      const nextGrantKeys = currentDraftGrantKeys.includes(grantKey)
        ? currentDraftGrantKeys.filter((currentGrantKey) => currentGrantKey !== grantKey)
        : [...currentDraftGrantKeys, grantKey];

      return {
        ...currentDrafts,
        [agentId]: nextGrantKeys,
      };
    });
  };

  const handleSaveProjectAccess = async (agent: TCodexAgent, selectedGrantKeys: string[]) => {
    const projectGrants = buildProjectGrantPayload(selectedGrantKeys);
    if (projectGrants.length === 0) {
      setToast({
        type: TOAST_TYPE.ERROR,
        title: "Выберите project",
        message: "У агента должен быть доступ хотя бы к одному project.",
      });
      return;
    }

    setSavingProjectGrantAgentIds((current) => ({ ...current, [agent.id]: true }));
    try {
      const grantsResponse = await codexAgentService.replaceProjectGrantsAcrossWorkspaces(workspaceSlug, agent.id, {
        grants: projectGrants,
        scopes: TASK_AUTHOR_SCOPES,
        mode: "voluntary",
      });
      setCreatedSetupCards((currentCards) =>
        currentCards.map((card) =>
          card.agent.id === agent.id
            ? {
                ...card,
                grants: mergeAgentGrants(card.grants, grantsResponse.grants, { replaceAllProjectGrants: true }),
              }
            : card
        )
      );
      setProjectGrantDrafts((currentDrafts) => {
        const nextDrafts = { ...currentDrafts };
        delete nextDrafts[agent.id];
        return nextDrafts;
      });
      await mutateSetupCards();
      setToast({
        type: TOAST_TYPE.SUCCESS,
        title: "Доступы Codex обновлены",
        message: "Agent token теперь работает только с выбранными workspace/projects.",
      });
    } catch (error: any) {
      setToast({
        type: TOAST_TYPE.ERROR,
        title: "Не удалось обновить доступы",
        message: error?.message ?? error?.error ?? "Проверьте project membership и Gateway.",
      });
    } finally {
      setSavingProjectGrantAgentIds((current) => ({ ...current, [agent.id]: false }));
    }
  };

  const handleSaveAgentName = async (agent: TCodexAgent) => {
    const displayName = getAgentDraftName(agentDraftNames, agent).trim();
    if (!displayName) return;
    if (displayName === agent.display_name) return;

    setUpdatingAgentIds((current) => ({ ...current, [agent.id]: true }));
    try {
      const response = await codexAgentService.updateAgent(workspaceSlug, agent.id, { display_name: displayName });
      setAgentDraftNames((currentDrafts) => {
        const nextDrafts = { ...currentDrafts };
        delete nextDrafts[agent.id];
        return nextDrafts;
      });
      setCreatedSetupCards((currentCards) =>
        currentCards.map((card) => (card.agent.id === agent.id ? { ...card, agent: response.agent } : card))
      );
      await mutateCodexAgents();
      await mutateSetupCards();
    } catch (error: any) {
      setToast({
        type: TOAST_TYPE.ERROR,
        title: "Не удалось сохранить агента",
        message: error?.message ?? error?.error ?? "Проверьте имя агента и Gateway.",
      });
    } finally {
      setUpdatingAgentIds((current) => ({ ...current, [agent.id]: false }));
    }
  };

  const handleRevokeAgent = async (agentId: string) => {
    await codexAgentService.revokeAgent(workspaceSlug, agentId);
    setCreatedSetupCards((currentCards) => currentCards.filter((card) => card.agent.id !== agentId));
    await mutateCodexAgents();
    await mutateSetupCards();
  };

  return (
    <div className="flex w-full flex-col gap-7">
      {showHeading && (
        <SettingsHeading
          title="Codex Agent API"
          description="Workspace-level вход в отдельный NODE.DC Agent Gateway. Внешний Codex получает только ограниченные agent grants, а не Plane session cookies или прямой Tasker API."
        />
      )}

      {isLoading ? (
        <div className="nodedc-settings-card text-sm px-5 py-5 text-secondary">Загрузка статуса модуля...</div>
      ) : (
        <>
          <section className="nodedc-settings-card px-5 py-5">
            <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
              <div className="min-w-0">
                <div className="flex min-w-0 items-center gap-2 text-16 font-semibold text-primary">
                  <Bot className="size-5 shrink-0 text-tertiary" />
                  <span className="min-w-0 truncate">Agent Gateway для {currentWorkspace?.name ?? workspaceSlug}</span>
                </div>
                <p className="mt-2 max-w-[56rem] text-13 leading-5 text-secondary">
                  Доступ к модулю приходит из Launcher entitlement Operational Core → Codex Agent API. Если entitlement
                  снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный
                  модуль.
                </p>
              </div>
              <div className="nodedc-settings-chip inline-flex h-11 w-fit items-center gap-2 text-13 font-medium text-primary">
                <span className="grid size-5 shrink-0 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]">
                  <Check className="size-3.5" />
                </span>
                <span>{isCodexAgentEntitled ? "Доступ выдан" : "Доступ не выдан"}</span>
              </div>
            </div>
          </section>

          <section className="grid gap-4 md:grid-cols-3">
            <CapabilityCard
              icon={ShieldCheck}
              title="Граница прав"
              description="Агент работает только в workspace/project grants и не получает права на удаление карточек, проектов, участников или состояний."
            />
            <CapabilityCard
              icon={Route}
              title="Маршрутизация"
              description="Все write-действия идут через отдельный Gateway и Tasker internal adapter, с audit trail и idempotency key."
            />
            <CapabilityCard
              icon={KeyRound}
              title="Локальный Codex"
              description="Пользовательский Codex подключается по MCP endpoint через одноразовый setup-code или legacy agent token."
            />
          </section>

          {isCodexAgentEntitled && (
            <>
              <section className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
                <div className="flex flex-col gap-1.5">
                  <div className="text-15 font-semibold text-primary">Создать агента workspace</div>
                  <p className="max-w-3xl text-12 leading-5 text-tertiary">
                    Задайте имя, выберите project grant и получите terminal-команду для локального Codex. Аватар
                    меняется кликом по кругу.
                  </p>
                </div>

                <div className="grid gap-4 xl:grid-cols-[auto_minmax(12rem,1fr)_minmax(12rem,1fr)_auto] xl:items-end">
                  <div className="grid gap-2.5">
                    <span className="text-body-sm-medium text-tertiary">Аватар</span>
                    <input
                      ref={createAvatarInputRef}
                      type="file"
                      accept={AGENT_AVATAR_ACCEPT}
                      className="hidden"
                      onChange={(event) => void handleCreateAvatarChange(event)}
                    />
                    <AgentAvatarButton
                      avatarUrl={newAgentAvatarUrl}
                      name={newAgentName}
                      onClick={() => createAvatarInputRef.current?.click()}
                    />
                  </div>
                  <label className="flex min-w-0 flex-col gap-2.5">
                    <span className="text-body-sm-medium text-tertiary">Задайте имя</span>
                    <input
                      className="nodedc-settings-input h-12 w-full px-4 text-13"
                      value={newAgentName}
                      onChange={(event) => setNewAgentName(event.target.value)}
                      placeholder="Имя агента"
                    />
                  </label>
                  <label className="flex min-w-0 flex-col gap-2.5">
                    <span className="text-body-sm-medium text-tertiary">Выберите project</span>
                    <select
                      className="nodedc-settings-select h-12 w-full px-4 text-13"
                      value={effectiveSelectedProjectKey}
                      onChange={(event) => setSelectedProjectKey(event.target.value)}
                    >
                      {projectOptions.map((project) => (
                        <option key={project.key} value={project.key}>
                          {project.workspaceName} · {project.name}
                        </option>
                      ))}
                    </select>
                  </label>
                  <Button
                    variant="primary"
                    size="lg"
                    className="nodedc-settings-save-button h-12 min-w-[11rem] self-end px-5"
                    disabled={!newAgentName.trim() || !effectiveSelectedProjectKey}
                    loading={isCreatingAgent}
                    onClick={handleCreateAgent}
                  >
                    Создать агента
                  </Button>
                </div>
              </section>

              {codexAgentsError && (
                <div className="bg-red-500/10 text-red-300 rounded-xl px-4 py-3 text-13">
                  Gateway недоступен или не настроен. Проверьте `PLANE_NODEDC_AGENT_GATEWAY_URL` и
                  `PLANE_NODEDC_AGENT_GATEWAY_TOKEN` в Tasker API runtime.
                </div>
              )}

              {areAgentsLoading ? (
                <div className="nodedc-settings-card px-5 py-5 text-13 text-secondary">Загрузка агентов...</div>
              ) : activeAgents.length > 0 ? (
                activeAgents.map((agent) => {
                  const draftName = getAgentDraftName(agentDraftNames, agent);
                  const isUpdatingAgent = updatingAgentIds[agent.id] === true;
                  const isCreatingSetupCode = creatingSetupCodeAgentIds[agent.id] === true;
                  const isCreatingToken = creatingTokenAgentIds[agent.id] === true;
                  const isAgentDirty = draftName.trim() !== agent.display_name;
                  const setupCard = setupCards.find((card) => card.agent.id === agent.id);
                  const agentTokens = setupCard?.tokens ?? [];
                  const agentGrants = setupCard?.grants ?? [];
                  const installCommand = setupCard?.installCommand;
                  const setupCode = setupCard?.setupCode;
                  const currentGrantKeys = getGrantedProjectKeys(agentGrants);
                  const draftGrantKeys = projectGrantDrafts[agent.id] ?? currentGrantKeys;
                  const isProjectAccessOpen = openProjectAccessAgentId === agent.id;
                  const isSavingProjectAccess = savingProjectGrantAgentIds[agent.id] === true;

                  return (
                    <section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
                      <div className="grid gap-4 xl:grid-cols-[auto_minmax(12rem,1fr)_minmax(12rem,0.8fr)_auto] xl:items-end">
                        <div className="grid gap-2.5">
                          <span className="text-body-sm-medium text-tertiary">Аватар</span>
                          <input
                            ref={(element) => {
                              agentAvatarInputRefs.current[agent.id] = element;
                            }}
                            type="file"
                            accept={AGENT_AVATAR_ACCEPT}
                            className="hidden"
                            onChange={(event) => void handleAgentAvatarChange(agent.id, event)}
                          />
                          <AgentAvatarButton
                            avatarUrl={agent.avatar_url}
                            disabled={isUpdatingAgent}
                            name={draftName}
                            onClick={() => agentAvatarInputRefs.current[agent.id]?.click()}
                          />
                        </div>
                        <label className="flex min-w-0 flex-col gap-2.5">
                          <span className="text-body-sm-medium text-tertiary">Имя агента</span>
                          <input
                            className="nodedc-settings-input h-12 w-full px-4 text-13"
                            value={draftName}
                            onChange={(event) =>
                              setAgentDraftNames((currentDrafts) => ({
                                ...currentDrafts,
                                [agent.id]: event.target.value,
                              }))
                            }
                            onBlur={() => {
                              if (isAgentDirty && !isUpdatingAgent) void handleSaveAgentName(agent);
                            }}
                            onKeyDown={(event) => {
                              if (event.key === "Enter") {
                                event.currentTarget.blur();
                              }
                            }}
                            placeholder="Имя агента"
                          />
                        </label>
                        <div className="flex min-w-0 flex-col gap-2.5">
                          <span className="text-body-sm-medium text-tertiary">Состояние</span>
                          <div className="nodedc-settings-input flex h-12 items-center px-4 text-13 text-secondary">
                            active · {new Date(agent.created_at).toLocaleString()}
                          </div>
                        </div>
                        <div className="flex flex-wrap gap-2 self-end">
                          <Button
                            variant="secondary"
                            size="sm"
                            className="nodedc-settings-chip h-12"
                            disabled={!isAgentDirty}
                            loading={isUpdatingAgent}
                            onClick={() => void handleSaveAgentName(agent)}
                          >
                            Сохранить
                          </Button>
                          <Button
                            variant="secondary"
                            size="sm"
                            className="nodedc-settings-chip h-12"
                            onClick={() => void handleRevokeAgent(agent.id)}
                          >
                            Отозвать
                          </Button>
                        </div>
                      </div>

                      <AgentTerminalSetupPanel
                        command={installCommand}
                        isCreating={isCreatingSetupCode}
                        setupCode={setupCode}
                        onCopyCommand={() => installCommand && void handleCopy(installCommand, "Terminal-команда")}
                        onCreate={() => void handleCreateSetupCode(agent)}
                      />

                      {areSetupCardsLoading && agentTokens.length === 0 ? (
                        <div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
                          Загрузка legacy-токенов...
                        </div>
                      ) : agentTokens.length > 0 ? (
                        <div className="grid gap-4">
                          {agentTokens.map((token) => {
                            const revealedToken = revealedTokens[token.id];
                            const tokenValue = revealedToken
                              ? stripCodexAgentTokenPrefix(revealedToken)
                              : maskToken(token);

                            return (
                              <div key={token.id} className="nodedc-settings-field p-4">
                                <div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
                                  Legacy agent token
                                </div>
                                <div className="relative">
                                  <code className="nodedc-settings-input flex h-12 w-full items-center overflow-hidden px-4 pr-14 text-12 text-primary">
                                    <span className="truncate">{tokenValue}</span>
                                  </code>
                                  {revealedToken && (
                                    <button
                                      type="button"
                                      aria-label="Скопировать токен"
                                      className="absolute top-1/2 right-1.5 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] transition hover:opacity-90"
                                      onClick={() =>
                                        void handleCopy(stripCodexAgentTokenPrefix(revealedToken), "Токен")
                                      }
                                    >
                                      <Copy className="size-4" />
                                    </button>
                                  )}
                                </div>
                                {revealedToken && (
                                  <p className="mt-3 text-12 leading-5 text-tertiary">
                                    Сохраните токен в надежное место. После обновления страницы полный токен будет
                                    недоступен.
                                  </p>
                                )}
                              </div>
                            );
                          })}
                        </div>
                      ) : (
                        <div className="nodedc-settings-field flex flex-col gap-3 px-4 py-4 text-13 text-secondary sm:flex-row sm:items-center sm:justify-between">
                          <span>
                            Legacy-токен ещё не выпущен. Используйте его только для ручной настройки config.toml.
                          </span>
                          <Button
                            variant="primary"
                            size="sm"
                            className="nodedc-settings-save-button shrink-0"
                            loading={isCreatingToken}
                            onClick={() => void handleCreateToken(agent)}
                          >
                            Сгенерировать токен
                          </Button>
                        </div>
                      )}

                      <AgentProjectAccessPanel
                        currentGrantKeys={currentGrantKeys}
                        draftGrantKeys={draftGrantKeys}
                        isOpen={isProjectAccessOpen}
                        isSaving={isSavingProjectAccess}
                        workspaceGroups={workspaceProjectGroups}
                        onSave={() => void handleSaveProjectAccess(agent, draftGrantKeys)}
                        onToggleOpen={() => {
                          setOpenProjectAccessAgentId(isProjectAccessOpen ? null : agent.id);
                          setProjectGrantDrafts((currentDrafts) =>
                            currentDrafts[agent.id]
                              ? currentDrafts
                              : {
                                  ...currentDrafts,
                                  [agent.id]: currentGrantKeys,
                                }
                          );
                        }}
                        onToggleProject={(grantKey) => handleToggleProjectGrant(agent.id, currentGrantKeys, grantKey)}
                      />
                    </section>
                  );
                })
              ) : (
                <div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
                  Агентов пока нет. Создайте агента, выберите project и сразу получите токен доступа.
                </div>
              )}

              {activeAgents.length > 0 && (
                <section className="nodedc-settings-card px-5 py-5">
                  <CodexConnectionGuide
                    configSnippet={connectionGuideConfigSnippet}
                    mcpEndpoint={connectionGuideMcpEndpoint}
                    onCopyConfig={() => void handleCopy(connectionGuideConfigSnippet, "MCP-блок")}
                  />
                </section>
              )}
            </>
          )}
        </>
      )}
    </div>
  );
});

type TCodexConnectionGuideProps = {
  configSnippet: string;
  mcpEndpoint: string;
  onCopyConfig: () => void;
};

type TAgentTerminalSetupPanelProps = {
  command?: string;
  isCreating: boolean;
  onCopyCommand: () => void;
  onCreate: () => void;
  setupCode?: TCodexAgentSetupCode;
};

function AgentTerminalSetupPanel(props: TAgentTerminalSetupPanelProps) {
  const expiresAtLabel = props.setupCode?.expires_at ? new Date(props.setupCode.expires_at).toLocaleString() : null;

  return (
    <div className="nodedc-settings-field p-4">
      <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
        <div className="flex min-w-0 items-center gap-3">
          <span className="grid size-10 shrink-0 place-items-center rounded-full bg-white/8 text-tertiary">
            <Terminal className="size-4" />
          </span>
          <div className="min-w-0">
            <div className="text-13 font-semibold text-primary">Recommended terminal setup</div>
            <div className="mt-1 text-12 text-tertiary">
              Одноразовая команда для macOS/Linux: создаст локальный token, обновит Codex config и установит ops-context
              skill.
            </div>
          </div>
        </div>
        <Button
          variant={props.command ? "secondary" : "primary"}
          size="sm"
          className={props.command ? "nodedc-settings-chip" : "nodedc-settings-save-button"}
          loading={props.isCreating}
          onClick={props.onCreate}
        >
          {props.command ? "Обновить команду" : "Получить команду"}
        </Button>
      </div>

      {props.command ? (
        <div className="mt-4 grid gap-3">
          <pre className="nodedc-settings-input font-mono max-h-40 w-full overflow-auto px-3 py-3 text-12 leading-5 break-all whitespace-pre-wrap text-primary">
            {props.command}
          </pre>
          <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <div className="text-12 text-tertiary">
              {expiresAtLabel ? `Действует до ${expiresAtLabel}. ` : ""}
              Команда не показывает agent token и сработает только один раз.
            </div>
            <Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyCommand}>
              <Copy className="mr-2 size-3.5" />
              Скопировать команду
            </Button>
          </div>
        </div>
      ) : (
        <div className="mt-4 rounded-2xl bg-white/[0.03] px-4 py-3 text-12 leading-5 text-tertiary">
          Нажмите «Получить команду» и вставьте ее в терминал на машине, где запущен локальный Codex.
        </div>
      )}
    </div>
  );
}

function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
  return (
    <div className="grid gap-4 p-4">
      <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
        <div>
          <div className="text-14 font-semibold text-primary">Legacy manual setup</div>
        </div>
        <span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
          MCP endpoint · {props.mcpEndpoint}
        </span>
      </div>

      <div className="grid gap-4 xl:grid-cols-3">
        <div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
          <div className="mb-2 font-semibold text-primary">1. Найдите config.toml</div>
          <p>Windows: откройте файл через проводник или VS Code.</p>
          <code className="mt-2 block text-12 break-all text-primary">
            C:\Users\имя-пользователя\.codex\config.toml
          </code>
          <p className="mt-3">macOS / Linux:</p>
          <code className="mt-2 block text-12 break-all text-primary">~/.codex/config.toml</code>
          <p className="mt-3">Если файла нет — создайте его.</p>
        </div>

        <div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
          <div className="mb-2 font-semibold text-primary">2. Скопируйте готовый MCP-блок</div>
          <p>
            Скопируйте блок ниже и вставьте его в конец <code>config.toml</code>. Если такой блок уже есть — замените
            только его.
          </p>
          <p className="mt-3">
            В строке <code>Authorization</code> замените только <code>ВАШ_УНИКАЛЬНЫЙ_ТОКЕН</code> на значение из{" "}
            <code>Agent token</code>. <code>Bearer</code> и <code>ndcag_</code> оставьте как есть.
          </p>
          <p className="mt-3">
            Не используйте <code>bearer_token_env_var</code>, если переменная окружения не добавлена в macOS launchd:
            Codex Desktop не увидит обычный <code>export</code> из shell-профиля.
          </p>
        </div>

        <div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
          <div className="mb-2 font-semibold text-primary">3. Перезапустите Codex</div>
          <p>
            Сохраните <code>config.toml</code> и перезапустите локальный Codex. После старта попросите Codex проверить
            доступные проекты через <code>tasker_list_projects</code>.
          </p>
          <p className="mt-3">
            Если токен или endpoint временно недоступны, чат Codex все равно откроется, но MCP tools появятся только
            после исправления подключения.
          </p>
          <p className="mt-3">Правила работы и grants Codex получит сам через MCP по токену.</p>
        </div>
      </div>

      <div className="nodedc-settings-field p-4">
        <div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">config.toml block</div>
        <p className="mb-3 text-13 leading-5 text-secondary">
          Добавьте этот блок в конец существующего <code>config.toml</code>. Не заменяйте файл целиком. Если блок{" "}
          <code>[mcp_servers.{CODEX_MCP_SERVER_NAME}]</code> уже есть — замените только этот блок и его{" "}
          <code>headers</code>.
        </p>
        <pre className="nodedc-settings-input font-mono w-full px-3 py-3 text-12 leading-5 break-all whitespace-pre-wrap text-primary">
          {props.configSnippet}
        </pre>
        <div className="mt-3 flex flex-wrap gap-2">
          <Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyConfig}>
            Скопировать MCP-блок
          </Button>
        </div>
      </div>
    </div>
  );
}

function getMcpEndpoint(setup?: TCodexAgentSetupPacket): string {
  return setup?.mcp_server?.url ?? DEFAULT_OPS_AGENT_MCP_ENDPOINT;
}

function buildCodexConfigSnippet(endpoint: string): string {
  return `[mcp_servers.${CODEX_MCP_SERVER_NAME}]
url = "${endpoint}"
enabled = true
required = false
startup_timeout_sec = 20
tool_timeout_sec = 60

[mcp_servers.${CODEX_MCP_SERVER_NAME}.headers]
Authorization = "Bearer ndcag_ВАШ_УНИКАЛЬНЫЙ_ТОКЕН"
Accept = "application/json"
"MCP-Protocol-Version" = "2025-06-18"`;
}

function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string {
  return agentDraftNames[agent.id] ?? agent.display_name;
}

function getInitials(name: string): string {
  const words = name.trim().split(/\s+/).filter(Boolean).slice(0, 2);
  const initials = words.map((word) => word[0]?.toUpperCase()).join("");
  return initials || "A";
}

function maskToken(token: TCodexAgentToken): string {
  return `${"×".repeat(16)}${token.token_suffix ?? token.id.slice(-8)}`;
}

function stripCodexAgentTokenPrefix(token: string): string {
  const trimmedToken = token.trim();
  return trimmedToken.startsWith(CODEX_AGENT_TOKEN_PREFIX)
    ? trimmedToken.slice(CODEX_AGENT_TOKEN_PREFIX.length)
    : trimmedToken;
}

function getAvatarSrc(avatarUrl?: string | null): string | null {
  if (!avatarUrl) return null;
  if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl;
  return getFileURL(avatarUrl) ?? null;
}

function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) {
  const sizeClassName = props.size === "lg" ? "size-12 text-16" : "size-10 text-13";
  const commonClassName = `${sizeClassName} shrink-0 overflow-hidden rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]`;
  const avatarSrc = getAvatarSrc(props.avatarUrl);

  if (avatarSrc) {
    return <img src={avatarSrc} alt={props.name} className={`${commonClassName} object-cover`} />;
  }

  return <div className={`${commonClassName} grid place-items-center font-semibold`}>{getInitials(props.name)}</div>;
}

function AgentAvatarButton(props: {
  avatarUrl?: string | null;
  disabled?: boolean;
  name: string;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      className="relative size-12 rounded-full transition outline-none hover:opacity-90 disabled:cursor-wait disabled:opacity-70"
      disabled={props.disabled}
      onClick={props.onClick}
      title="Изменить аватар"
    >
      <AgentAvatar avatarUrl={props.avatarUrl} name={props.name} size="lg" />
    </button>
  );
}

type TAgentProjectAccessPanelProps = {
  currentGrantKeys: string[];
  draftGrantKeys: string[];
  isOpen: boolean;
  isSaving: boolean;
  onSave: () => void;
  onToggleOpen: () => void;
  onToggleProject: (grantKey: string) => void;
  workspaceGroups: TCodexAgentGrantableWorkspace[];
};

function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) {
  const isDirty = !areProjectSelectionsEqual(props.currentGrantKeys, props.draftGrantKeys);
  const selectedCount = props.draftGrantKeys.length;
  const projectsCount = props.workspaceGroups.reduce((count, group) => count + group.projects.length, 0);
  const summary =
    selectedCount === 0
      ? "Нет выбранных projects"
      : selectedCount === 1
        ? "1 project выбран"
        : `${selectedCount} projects выбрано`;

  return (
    <div className="nodedc-settings-field p-4">
      <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
        <div className="flex min-w-0 items-center gap-3">
          <span className="grid size-10 shrink-0 place-items-center rounded-full bg-white/8 text-tertiary">
            <FolderKanban className="size-4" />
          </span>
          <div className="min-w-0">
            <div className="text-13 font-semibold text-primary">Доступы к workspace и projects</div>
            <div className="mt-1 text-12 text-tertiary">
              Выберите workspaces/projects, куда этот agent token может читать и писать карточки.
            </div>
          </div>
        </div>
        <button
          type="button"
          className="nodedc-settings-chip inline-flex h-11 w-fit items-center gap-2 px-4 text-12 text-primary outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none"
          onClick={props.onToggleOpen}
        >
          <span>{summary}</span>
          <ChevronDown className={`size-4 transition ${props.isOpen ? "rotate-180" : ""}`} />
        </button>
      </div>

      {props.isOpen && (
        <div className="nodedc-project-grants-surface mt-4 rounded-3xl p-3">
          {projectsCount > 0 ? (
            <div className="grid max-h-96 gap-3 overflow-y-auto pr-1">
              {props.workspaceGroups.map((workspaceGroup) => {
                if (workspaceGroup.projects.length === 0) return null;

                return (
                  <div key={workspaceGroup.slug} className="nodedc-project-grants-workspace rounded-2xl p-2">
                    <div className="px-1 pb-1.5">
                      <div className="truncate text-12 font-semibold text-primary">{workspaceGroup.name}</div>
                      <div className="mt-0.5 truncate text-10 tracking-wide text-tertiary uppercase">
                        {workspaceGroup.slug}
                      </div>
                    </div>
                    <div className="grid gap-0">
                      {workspaceGroup.projects.map((project) => {
                        const grantKey = buildProjectGrantKey(workspaceGroup.slug, project.id);
                        const isChecked = props.draftGrantKeys.includes(grantKey);

                        return (
                          <button
                            key={grantKey}
                            type="button"
                            className="nodedc-project-grants-row flex min-h-12 items-center justify-between gap-3 rounded-2xl px-3 py-2 text-left outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none"
                            onClick={() => props.onToggleProject(grantKey)}
                          >
                            <span className="min-w-0">
                              <span className="block truncate text-13 font-medium text-primary">{project.name}</span>
                              {project.identifier && (
                                <span className="mt-0.5 block truncate text-11 text-tertiary">
                                  {project.identifier}
                                </span>
                              )}
                            </span>
                            <span
                              className={`nodedc-project-grants-check grid size-5 shrink-0 place-items-center rounded-full transition ${
                                isChecked
                                  ? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
                                  : "bg-white/5 text-transparent"
                              }`}
                            >
                              <Check className="size-3.5" />
                            </span>
                          </button>
                        );
                      })}
                    </div>
                  </div>
                );
              })}
            </div>
          ) : (
            <div className="px-3 py-4 text-13 text-secondary">Нет доступных workspace/projects.</div>
          )}

          <div className="mt-3 flex flex-col gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between">
            <div className="text-12 text-tertiary">
              Сохранение заменяет grants выбранных workspace/projects: снятые галочки сразу отзывают доступ.
            </div>
            <Button
              variant="primary"
              size="sm"
              className="nodedc-settings-save-button"
              disabled={!isDirty || selectedCount === 0}
              loading={props.isSaving}
              onClick={props.onSave}
            >
              Сохранить доступы
            </Button>
          </div>
        </div>
      )}
    </div>
  );
}

function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgentSetupCard[]): TAgentSetupCard[] {
  const cardsByAgentId = new Map<string, TAgentSetupCard>();

  for (const card of persistedCards) {
    cardsByAgentId.set(card.agent.id, card);
  }

  for (const card of createdCards) {
    const persistedCard = cardsByAgentId.get(card.agent.id);
    if (!persistedCard) {
      cardsByAgentId.set(card.agent.id, card);
      continue;
    }

    cardsByAgentId.set(card.agent.id, {
      agent: persistedCard.agent,
      grants: mergeAgentGrants(persistedCard.grants, card.grants),
      installCommand: card.installCommand ?? persistedCard.installCommand,
      setup: persistedCard.setup ?? card.setup,
      setupCode: card.setupCode ?? persistedCard.setupCode,
      tokens: mergeTokens(persistedCard.tokens, card.tokens),
    });
  }

  return Array.from(cardsByAgentId.values());
}

function mergeTokens(primaryTokens: TCodexAgentToken[], secondaryTokens: TCodexAgentToken[]): TCodexAgentToken[] {
  const tokensById = new Map<string, TCodexAgentToken>();
  for (const token of primaryTokens) tokensById.set(token.id, token);
  for (const token of secondaryTokens) tokensById.set(token.id, tokensById.get(token.id) ?? token);
  // oxlint-disable-next-line unicorn/no-array-sort
  return Array.from(tokensById.values()).sort(
    (leftToken, rightToken) => new Date(rightToken.created_at).getTime() - new Date(leftToken.created_at).getTime()
  );
}

function upsertSetupCardToken(
  cards: TAgentSetupCard[],
  agent: TCodexAgent,
  token: TCodexAgentToken,
  setup?: TCodexAgentSetupPacket,
  grants: TCodexAgentGrant[] = []
): TAgentSetupCard[] {
  const existingCard = cards.find((card) => card.agent.id === agent.id);
  if (!existingCard) {
    return [{ agent, grants, setup, tokens: [token] }, ...cards];
  }

  return cards.map((card) =>
    card.agent.id === agent.id
      ? {
          agent,
          grants: mergeAgentGrants(card.grants, grants),
          installCommand: card.installCommand,
          setup: setup ?? card.setup,
          setupCode: card.setupCode,
          tokens: mergeTokens([token], card.tokens),
        }
      : card
  );
}

function upsertSetupCardSetupCode(
  cards: TAgentSetupCard[],
  agent: TCodexAgent,
  setupCode: TCodexAgentSetupCode,
  installCommand: string,
  setup?: TCodexAgentSetupPacket,
  grants: TCodexAgentGrant[] = []
): TAgentSetupCard[] {
  const existingCard = cards.find((card) => card.agent.id === agent.id);
  if (!existingCard) {
    return [{ agent, grants, installCommand, setup, setupCode, tokens: [] }, ...cards];
  }

  return cards.map((card) =>
    card.agent.id === agent.id
      ? {
          agent,
          grants: mergeAgentGrants(card.grants, grants),
          installCommand,
          setup: setup ?? card.setup,
          setupCode,
          tokens: card.tokens,
        }
      : card
  );
}

function flattenProjectAccessOptions(workspaceGroups: TCodexAgentGrantableWorkspace[]): TProjectAccessOption[] {
  return workspaceGroups.flatMap((workspaceGroup) =>
    workspaceGroup.projects.map((project) => ({
      id: project.id,
      identifier: project.identifier,
      key: buildProjectGrantKey(workspaceGroup.slug, project.id),
      name: project.name,
      workspaceName: workspaceGroup.name,
      workspaceSlug: workspaceGroup.slug,
    }))
  );
}

function buildProjectGrantKey(workspaceSlug: string, projectId: string): string {
  return `${workspaceSlug}:${projectId}`;
}

function parseProjectGrantKey(grantKey: string): { workspaceSlug: string; projectId: string } | null {
  const separatorIndex = grantKey.indexOf(":");
  if (separatorIndex <= 0 || separatorIndex >= grantKey.length - 1) return null;

  return {
    workspaceSlug: grantKey.slice(0, separatorIndex),
    projectId: grantKey.slice(separatorIndex + 1),
  };
}

function buildProjectGrantPayload(grantKeys: string[]): { workspace_slug: string; project_id: string }[] {
  const seenKeys = new Set<string>();
  const grants: { workspace_slug: string; project_id: string }[] = [];

  for (const grantKey of grantKeys) {
    if (seenKeys.has(grantKey)) continue;
    seenKeys.add(grantKey);

    const parsedGrant = parseProjectGrantKey(grantKey);
    if (!parsedGrant) continue;

    grants.push({
      workspace_slug: parsedGrant.workspaceSlug,
      project_id: parsedGrant.projectId,
    });
  }

  return grants;
}

function getGrantedProjectKeys(grants: TCodexAgentGrant[]): string[] {
  const grantedProjectKeys = [
    ...new Set(
      grants
        .filter((grant) => grant.project_id)
        .map((grant) => buildProjectGrantKey(grant.workspace_slug, String(grant.project_id)))
    ),
  ];

  // oxlint-disable-next-line unicorn/no-array-sort
  return grantedProjectKeys.sort();
}

type TMergeAgentGrantsOptions = {
  replaceAllProjectGrants?: boolean;
  workspaceSlug?: string;
};

function mergeAgentGrants(
  currentGrants: TCodexAgentGrant[],
  nextGrants: TCodexAgentGrant[],
  options: TMergeAgentGrantsOptions = {}
): TCodexAgentGrant[] {
  const grantsByKey = new Map<string, TCodexAgentGrant>();

  for (const grant of currentGrants) {
    if (options.replaceAllProjectGrants && grant.project_id) continue;
    if (options.workspaceSlug && grant.workspace_slug === options.workspaceSlug) continue;
    grantsByKey.set(buildGrantKey(grant), grant);
  }

  for (const grant of nextGrants) {
    grantsByKey.set(buildGrantKey(grant), grant);
  }

  return Array.from(grantsByKey.values());
}

function buildGrantKey(grant: TCodexAgentGrant): string {
  return `${grant.workspace_slug}:${grant.project_id ?? "*"}`;
}

function areProjectSelectionsEqual(leftProjectIds: string[], rightProjectIds: string[]): boolean {
  const leftSet = new Set(leftProjectIds);
  const rightSet = new Set(rightProjectIds);
  if (leftSet.size !== rightSet.size) return false;

  for (const projectId of leftSet) {
    if (!rightSet.has(projectId)) return false;
  }

  return true;
}

function readAvatarDataUrl(file: File): Promise<string> {
  if (!file.type.startsWith("image/")) {
    return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));
  }

  if (file.size > MAX_AGENT_AVATAR_SOURCE_BYTES) {
    return Promise.reject(new Error("Исходный файл аватара должен быть не больше 50 МБ."));
  }

  return resizeAvatarFile(file);
}

async function resizeAvatarFile(file: File): Promise<string> {
  const objectUrl = URL.createObjectURL(file);
  try {
    const image = await loadImageElement(objectUrl);
    const sourceWidth = image.naturalWidth || image.width;
    const sourceHeight = image.naturalHeight || image.height;
    if (!sourceWidth || !sourceHeight) {
      throw new Error("Не удалось определить размер изображения.");
    }

    const scale = Math.min(1, AGENT_AVATAR_RENDER_SIZE / Math.max(sourceWidth, sourceHeight));
    const width = Math.max(1, Math.round(sourceWidth * scale));
    const height = Math.max(1, Math.round(sourceHeight * scale));
    const canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;
    const context = canvas.getContext("2d");
    if (!context) {
      throw new Error("Не удалось подготовить аватар.");
    }

    context.drawImage(image, 0, 0, width, height);
    const blob = await canvasToBlob(canvas, "image/webp", AGENT_AVATAR_OUTPUT_QUALITY);
    return blobToDataUrl(blob);
  } finally {
    URL.revokeObjectURL(objectUrl);
  }
}

function loadImageElement(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.addEventListener("load", () => resolve(image), { once: true });
    image.addEventListener("error", () => reject(new Error("Не удалось открыть изображение аватара.")), { once: true });
    image.src = src;
  });
}

function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => {
        if (blob) {
          resolve(blob);
          return;
        }
        reject(new Error("Не удалось сжать аватар."));
      },
      type,
      quality
    );
  });
}

function blobToDataUrl(blob: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.addEventListener("load", () => resolve(String(reader.result)), { once: true });
    reader.addEventListener("error", () => reject(new Error("Не удалось прочитать файл аватара.")), { once: true });
    reader.readAsDataURL(blob);
  });
}

function showAvatarError(message?: string) {
  setToast({
    type: TOAST_TYPE.ERROR,
    title: "Не удалось загрузить аватар",
    message: message ?? "Проверьте формат и размер изображения.",
  });
}

type TCapabilityCardProps = {
  description: string;
  icon: typeof ShieldCheck;
  title: string;
};

function CapabilityCard(props: TCapabilityCardProps) {
  const Icon = props.icon;

  return (
    <div className="nodedc-settings-card px-5 py-5">
      <div className="flex items-center gap-2 text-14 font-semibold text-primary">
        <Icon className="size-4 text-tertiary" />
        <span>{props.title}</span>
      </div>
      <p className="mt-3 text-13 leading-5 text-secondary">{props.description}</p>
    </div>
  );
}
