import { Injectable, Logger } from '@nestjs/common';
import type {
  McpResult,
  ListAppsInput,
  ListAppsOutput,
  DeployPrepareInput,
  DeployPrepareOutput,
  LogsInput,
  LogsOutput,
  EnvInput,
  DestroyInput,
  DestroyOutput,
  McpToolName,
} from '../dto/mcp.dto';
import { PrismaService } from '@core/database/prisma/prisma.service';
import { SkipAssertAccess } from '@common/decorators/skip-assert-access.decorator';
import { InternalAppPlatformService } from '../internal-app-platform.service';
import { InternalAppSlugService } from './slug.service';
import { InternalAppEnvCryptoService } from './env-crypto.service';
import { GiteaClientService, GiteaError } from './gitea-client.service';
import { ContainerHostService } from './container-host.service';
import { InternalAppEventsService, EventType } from './events.service';

const RETENTION_DAYS = 30;
const ENV_VALUE_MAX_BYTES = 4 * 1024;
const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]{0,63}$/;
const RESERVED_ENV_PREFIXES = ['FFOA_', 'PLATFORM_'];

const ONBOARD_URL = 'https://ffworkspace.faradayfuture.com/internal-apps';
const PUBLIC_DOMAIN = process.env.INTERNAL_APP_PUBLIC_DOMAIN || 'apps.ffworkspace.faradayfuture.com';

/**
 * MCP 工具实现层
 *
 * 5 个工具：list_apps / deploy_prepare / logs / env / destroy
 * 每个工具方法接收已鉴权的 employeeSlug + 工具入参，返回结构化结果。
 * 详见 07-api §3
 *
 * 骨架阶段：list_apps 已可用；deploy_prepare 实现 runtime 判别 + unsupported_runtime；
 * logs / env / destroy 留占位待 Phase 0 中后期填充。
 */
@Injectable()
export class InternalAppMcpToolsService {
  private readonly logger = new Logger(InternalAppMcpToolsService.name);

  constructor(
    private readonly platformSvc: InternalAppPlatformService,
    private readonly slugSvc: InternalAppSlugService,
    private readonly prisma: PrismaService,
    private readonly cryptoSvc: InternalAppEnvCryptoService,
    private readonly giteaSvc: GiteaClientService,
    private readonly containerHost: ContainerHostService,
    private readonly eventsSvc: InternalAppEventsService,
  ) {}

  /**
   * 工具调用主入口（SDK 接管 tools/list 路由，本服务只需要 callTool）
   */
  async callTool(
    employeeSlug: string,
    name: McpToolName,
    args: Record<string, unknown> | undefined,
  ): Promise<McpResult<unknown>> {
    this.logger.debug(`callTool name=${name} employeeSlug=${employeeSlug}`);

    const argsAny = (args ?? {}) as unknown;

    switch (name) {
      case 'list_apps':
        return this.listApps(employeeSlug, argsAny as ListAppsInput);
      case 'deploy_prepare':
        return this.deployPrepare(employeeSlug, argsAny as DeployPrepareInput);
      case 'logs':
        return this.logs(employeeSlug, argsAny as LogsInput);
      case 'env':
        return this.env(employeeSlug, argsAny as EnvInput);
      case 'destroy':
        return this.destroy(employeeSlug, argsAny as DestroyInput);
      default:
        return this.error('unknown_tool', `工具 "${String(name)}" 不存在`);
    }
  }

  // ==========================================================================
  // list_apps（PRD F2.3）
  // ==========================================================================
  private async listApps(
    employeeSlug: string,
    input: ListAppsInput,
  ): Promise<McpResult<ListAppsOutput>> {
    const apps = await this.platformSvc.listMyApps(
      employeeSlug,
      input.includeDestroyed === true,
    );

    return {
      ok: true,
      data: {
        apps: apps.map((a) => ({
          id: a.id,
          appSlug: a.appSlug,
          displayName: a.displayName,
          runtime: a.runtime,
          status: a.status,
          url: a.url,
          lastDeployedAt: a.lastDeployedAt?.toISOString() ?? null,
          destroyedAt: a.destroyedAt?.toISOString() ?? null,
          retentionUntil: a.retentionUntil?.toISOString() ?? null,
        })),
      },
    };
  }

  // ==========================================================================
  // deploy_prepare（PRD F1.1/F1.2/F1.3）
  // ==========================================================================
  private async deployPrepare(
    employeeSlug: string,
    input: DeployPrepareInput,
  ): Promise<McpResult<DeployPrepareOutput>> {
    // 1. slug 校验
    if (!input.appSlug) {
      return this.error('invalid_slug', 'appSlug 是必填项');
    }
    const slugCheck = this.slugSvc.validateAppSlug(input.appSlug);
    if (!slugCheck.ok) {
      const code = slugCheck.reason.includes('保留字')
        ? 'reserved_slug'
        : 'invalid_slug';
      return this.error(code, slugCheck.reason, {
        receivedSlug: input.appSlug,
      });
    }

    // 2. runtime 判别 — 不是 node/static 一律 unsupported_runtime（07-api §3.2 case A）
    const detected = input.detected ?? {
      hasPackageJson: false,
      hasStartScript: false,
      hasIndexHtml: false,
    };
    const runtimeResult = this.detectRuntime(detected);
    if (runtimeResult.runtime === null) {
      return this.error(
        'unsupported_runtime',
        '未识别 runtime——需要 package.json + start script（node）或 index.html（static）',
        { detected, hint: runtimeResult.hint },
      );
    }

    // 3. 终态守卫：DB 行处于 DESTROYED / DISABLED_ARCHIVED / PURGED 时拒绝重 deploy
    //    （Gitea getRepo 不区分 archived 状态总返 exists=true，没这道闸门会让员工
     //     拿到 push token + appId、push 后 webhook 看到 status=DESTROYED 静默 ignore，
    //     完全无错误信号。语义：terminal state 必须先走"恢复"流程才能再 deploy；MVP 不支持恢复 → 拒绝。）
    const existingApp = await this.prisma.internalApp.findUnique({
      where: { giteaRepoFullName: `FFAIApps/${employeeSlug}-${input.appSlug}` },
      select: { status: true, destroyedAt: true, retentionUntil: true },
    });
    if (existingApp && ['DESTROYED', 'DISABLED_ARCHIVED', 'PURGED'].includes(existingApp.status)) {
      return this.error(
        'app_in_terminal_state',
        `app "${input.appSlug}" 处于 ${existingApp.status} 状态，无法重新部署。MVP 不支持销毁后恢复——请换一个 app slug 重新部署`,
        {
          status: existingApp.status,
          destroyedAt: existingApp.destroyedAt?.toISOString() ?? null,
          retentionUntil: existingApp.retentionUntil?.toISOString() ?? null,
        },
      );
    }

    // 4. Gitea 探仓库存在性（首次 vs 增量）。变量名 giteaProbe 显式区分上面的
    // existingApp（DB 终态行）—— 两者语义不同，diff 阅读不要混淆。
    const giteaProbe = await this.giteaSvc.getRepo({
      employeeSlug,
      appSlug: input.appSlug,
    });
    if (!giteaProbe.ok) {
      return this.translateGiteaError(giteaProbe.error);
    }

    let isFirstDeploy: boolean;
    let repoCloneUrl: string;
    if (giteaProbe.exists && giteaProbe.repo) {
      isFirstDeploy = false;
      repoCloneUrl = giteaProbe.repo.cloneUrl;
    } else {
      // 首次部署：建仓
      const created = await this.giteaSvc.createRepo({
        employeeSlug,
        appSlug: input.appSlug,
      });
      if (!created.ok) {
        return this.translateGiteaError(created.error);
      }
      isFirstDeploy = true;
      repoCloneUrl = created.repo.cloneUrl;
    }

    // 5. 确保该仓库装有指向本环境 backend 的 push webhook（幂等）。
    //    历史 bug：createRepo 从不装 per-repo hook，依赖 org-level；org-level URL 漂到死 IP 后
    //    所有新建仓库 push 完静默丢消息，员工拿到 200 空 body 死胡同。修复见
    //    .learnings/2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md
    const fullNameForHook = `FFAIApps/${employeeSlug}-${input.appSlug}`;
    const hookResult = await this.giteaSvc.ensureWebhook(fullNameForHook);
    if (!hookResult.ok) {
      return this.translateGiteaError(hookResult.error);
    }

    // 6. 颁发 push 凭据（Phase 0 MVP: 平台 long-term token；V2 改 ephemeral）
    const credResult = this.giteaSvc.issuePushCredential();
    if (!credResult.ok) {
      return this.translateGiteaError(credResult.error);
    }

    // 7. Upsert internal_apps 行——webhook handler 要求该行存在才会触发部署链。
    //    历史上 deploy_prepare 不写这张表，导致 push→webhook 收到后 404 找不到 app，
    //    整个 PoC 端到端走不通；详见 .learnings/2026-05-18-mcp-controller-not-jsonrpc-compliant.md "为什么之前没发现"。
    //
    //    幂等：首次/增量都 upsert（增量场景行已存在，update 不动 status/url/runtime 等 webhook 关心的字段，只刷新 updatedAt）。
    const binding = await this.prisma.employeeSlugBinding.findUnique({
      where: { employeeSlug },
      select: { userId: true, organizationId: true },
    });
    if (!binding) {
      return this.error(
        'internal_error',
        'employee slug binding 缺失（应在 token issue 时已建）',
      );
    }
    const fullName = `FFAIApps/${employeeSlug}-${input.appSlug}`;
    const url = `https://${employeeSlug}-${input.appSlug}.${PUBLIC_DOMAIN}`;
    const upserted = await this.prisma.internalApp.upsert({
      where: { giteaRepoFullName: fullName },
      create: {
        employeeSlug,
        appSlug: input.appSlug,
        displayName: input.displayName ?? null,
        runtime: runtimeResult.runtime,
        status: 'PENDING',
        url,
        giteaRepoFullName: fullName,
        organizationId: binding.organizationId,
        createdById: binding.userId,
      },
      update: {
        // 增量部署不改 status/url/runtime（status 由 webhook + deploy script 推进）。
        // 仅 display_name 是员工可在 deploy_prepare 时刷新的字段。
        ...(input.displayName !== undefined && { displayName: input.displayName }),
      },
      select: { id: true },
    });

    return {
      ok: true,
      data: {
        appId: upserted.id,
        isFirstDeploy,
        runtime: runtimeResult.runtime,
        repoUrl: repoCloneUrl,
        pushCredential: {
          token: credResult.credential.token,
          expiresAt: credResult.credential.expiresAt.toISOString(),
        },
        branch: 'main' as const,
        suggestedCommitMessage: `deploy ${new Date().toISOString()}`,
        url,
        postDeployHint: `代码已推到 Gitea。平台 webhook 接到 push 后会拉起构建并部署，通常 30 秒到 5 分钟完成。请告知用户：约 5 分钟后访问 ${url} 验证；如仍无法访问，调 logs 工具看日志。若 logs 返 container_not_found 且 details.lastDeployFailure 非空，说明 build 阶段失败，把 details.lastDeployFailure.errorCode + message 告诉用户，让他修源码后再推一遍。push 完成 ≠ 上线完成，不要立即声称部署成功。`,
      },
    };
  }

  /** Gitea 错误 → MCP error 转换（保留 details 便于 Claude Code 友好提示） */
  private translateGiteaError(err: GiteaError): McpResult<never> {
    return {
      ok: false,
      error: {
        code: err.code,
        message: err.message,
        details: { ...err },
      },
    };
  }

  /**
   * runtime 自动判别（PRD §核心约束 + 03 §4.6）
   * 返回 runtime 或 null（unsupported）+ 友好 hint
   */
  private detectRuntime(
    detected: NonNullable<DeployPrepareInput['detected']>,
  ): { runtime: 'node' | 'static' | null; hint?: string } {
    if (detected.hasPackageJson && detected.hasStartScript) {
      return { runtime: 'node' };
    }
    if (detected.hasIndexHtml && !detected.hasPackageJson) {
      return { runtime: 'static' };
    }

    // 友好 hint：识别已知非 node/static 信号
    if (detected.hasDockerfile) {
      return {
        runtime: null,
        hint: '检测到 Dockerfile，但当前平台 MVP 只支持 Node + static HTML，自定义 Docker 镜像不支持。建议改用 Node 重写或等待 V2 容器镜像支持。',
      };
    }
    if (detected.hasRequirementsTxt) {
      return {
        runtime: null,
        hint: '检测到 requirements.txt（Python 项目），当前平台 MVP 不支持 Python，等待 V2 Python 运行时。',
      };
    }
    if (detected.hasGoMod) {
      return {
        runtime: null,
        hint: '检测到 go.mod（Go 项目），当前平台 MVP 不支持 Go，等待 V2。',
      };
    }
    if (detected.hasPomXml) {
      return {
        runtime: null,
        hint: '检测到 pom.xml（Java/Maven 项目），当前平台 MVP 不支持 Java。',
      };
    }
    if (detected.hasPackageJson && !detected.hasStartScript) {
      return {
        runtime: null,
        hint: '检测到 package.json 但缺 start script，请在 scripts 里加一条 "start": "node index.js"（或对应入口）即可。',
      };
    }

    return {
      runtime: null,
      hint: '需要 package.json + start script（node runtime）或根目录 index.html（static runtime）。',
    };
  }

  // ==========================================================================
  // logs / env / destroy — 骨架占位
  // ==========================================================================
  /**
   * logs（PRD F2.1）：取 app 容器近 N 行日志
   *
   * - 校验 app 归属当前 employeeSlug（防 IDOR）
   * - 调 ContainerHostService 走 docker logs --tail N
   * - DESTROYED / DISABLED_ARCHIVED 状态下容器已不存在，给结构化错误而非裸 docker 错
   */
  private async logs(
    employeeSlug: string,
    input: LogsInput,
  ): Promise<McpResult<LogsOutput>> {
    if (!input.appSlug) {
      return this.error('invalid_request', '缺 appSlug');
    }

    const app = await this.prisma.internalApp.findUnique({
      where: { uq_app_per_employee: { employeeSlug, appSlug: input.appSlug } },
    });
    if (!app) {
      return this.error('app_not_found', `未找到 app "${input.appSlug}"`);
    }
    if (
      app.status === 'DESTROYED' ||
      app.status === 'DISABLED_ARCHIVED' ||
      app.status === 'PURGED'
    ) {
      return this.error(
        'app_destroyed',
        `app 处于 ${app.status} 状态，容器已停，无日志可取`,
      );
    }

    const tail = Math.min(Math.max(input.lines ?? 100, 1), 1000);
    const containerName = this.containerHost.buildContainerName(
      employeeSlug,
      input.appSlug,
    );

    try {
      const { stdout, truncatedByLineCap } =
        await this.containerHost.getContainerLogs(containerName, tail);
      return {
        ok: true,
        data: {
          appSlug: input.appSlug,
          runtime: app.runtime as 'node' | 'static',
          logs: stdout,
          truncated: truncatedByLineCap,
          fetchedAt: new Date().toISOString(),
        },
      };
    } catch (err) {
      const msg = (err as Error).message;
      if (msg === 'container_not_found') {
        // 容器不存在 → 自动捞最近一次 APP_DEPLOY_FAILED 事件回填，
        // 让 Claude 拿到 build-stage 错误（npm install 失败 / 缺 start script 等），
        // 否则员工看到 "container doesn't exist" 死胡同，build 错误埋在 events 表无人去捞。
        // 详见 .learnings/2026-05-19-logs-tool-needs-build-stage-fallback.md
        const lastFailure = await this.prisma.internalAppEvent.findFirst({
          where: { appId: app.id, eventType: 'app.deploy_failed' },
          orderBy: { createdAt: 'desc' },
          select: { errorCode: true, payload: true, createdAt: true },
        });
        const failurePayload =
          (lastFailure?.payload as { message?: string; commitSha?: string } | null) ?? null;
        return this.error(
          'container_not_found',
          lastFailure
            ? `容器 ${containerName} 不存在 — 最近一次部署失败（${lastFailure.createdAt.toISOString()}）。errorCode=${lastFailure.errorCode ?? 'unknown'}；详见 details.lastDeployFailure`
            : `容器 ${containerName} 不存在（尚未首次部署 / 正在构建中 / 或部署成功前已被清理）。如刚 push 不到 5 分钟，请等 5 分钟后重试`,
          {
            containerName,
            appStatus: app.status,
            lastDeployFailure: lastFailure
              ? {
                  errorCode: lastFailure.errorCode,
                  message: failurePayload?.message ?? null,
                  commitSha: failurePayload?.commitSha ?? null,
                  failedAt: lastFailure.createdAt.toISOString(),
                }
              : null,
          },
        );
      }
      if (msg === 'host_unreachable') {
        return this.error('host_unreachable', '部署宿主当前不可达，请稍后重试');
      }
      this.logger.error(`logs failed appSlug=${input.appSlug}: ${msg}`);
      return this.error('docker_logs_failed', '取日志失败，请联系平台管理员');
    }
  }

  /**
   * env（PRD F2.2）：list / get / set / unset 4 子动作
   *
   * set / unset 在真实实现里会**触发滚动重启**（复用现有镜像 + 新 env 启新容器），
   * 骨架阶段仅完成 DB 写入 + 加密；rolling restart 在 Phase 0 中期接入。
   */
  private async env(
    employeeSlug: string,
    input: EnvInput,
  ): Promise<McpResult<unknown>> {
    if (!input.appSlug || !input.action) {
      return this.error('invalid_request', '缺 appSlug 或 action');
    }

    const app = await this.prisma.internalApp.findUnique({
      where: {
        uq_app_per_employee: { employeeSlug, appSlug: input.appSlug },
      },
    });
    if (!app) {
      return this.error('app_not_found', `未找到 app "${input.appSlug}"`);
    }

    switch (input.action) {
      case 'list':
        return this.envList(app.id, app.organizationId);
      case 'get':
        return this.envGet(app.id, input.key);
      case 'set':
        return this.envSet(app.id, app.organizationId, employeeSlug, input.key, input.value);
      case 'unset':
        return this.envUnset(app.id, app.organizationId, employeeSlug, input.key);
      default:
        return this.error('invalid_request', `未知 action "${String(input.action)}"`);
    }
  }

  private async envList(appId: string, organizationId: string) {
    const rows = await this.prisma.internalAppEnvVar.findMany({
      where: { appId, organizationId },
      orderBy: { key: 'asc' },
    });
    return {
      ok: true as const,
      data: {
        appId,
        envVars: rows.map((r) => ({
          key: r.key,
          // 不解密 list 场景的 value（避免热路径解密成本）；只显示密文长度作 preview
          valuePreview: r.valueEncrypted.length > 0 ? '****' : '',
        })),
      },
    };
  }

  private async envGet(appId: string, key: string | undefined) {
    if (!key) return this.error('invalid_request', 'get 必须传 key');
    const row = await this.prisma.internalAppEnvVar.findUnique({
      where: { uq_env_per_app: { appId, key } },
    });
    if (!row) return this.error('env_not_found', `env key "${key}" 不存在`);

    const value = this.cryptoSvc.decrypt(
      Buffer.from(row.valueEncrypted),
      Buffer.from(row.valueIv),
      row.kmsKeyVersion,
    );
    return { ok: true as const, data: { key, value } };
  }

  private async envSet(
    appId: string,
    organizationId: string,
    employeeSlug: string,
    key: string | undefined,
    value: string | undefined,
  ) {
    if (!key) return this.error('invalid_request', 'set 必须传 key');
    if (value === undefined)
      return this.error('invalid_request', 'set 必须传 value');

    // key 格式
    if (!ENV_KEY_PATTERN.test(key)) {
      return this.error(
        'invalid_env_key',
        'env key 必须是大写字母开头，仅含 A-Z 0-9 _，长度 ≤ 64',
        { receivedKey: key },
      );
    }
    // key 保留前缀
    for (const prefix of RESERVED_ENV_PREFIXES) {
      if (key.startsWith(prefix)) {
        return this.error(
          'reserved_env_prefix',
          `env key 不能以 "${prefix}" 开头（平台保留前缀）`,
          { receivedKey: key, reservedPrefixes: RESERVED_ENV_PREFIXES },
        );
      }
    }
    // value 长度
    if (Buffer.byteLength(value, 'utf8') > ENV_VALUE_MAX_BYTES) {
      return this.error(
        'env_value_too_large',
        `env value 不能超过 ${ENV_VALUE_MAX_BYTES} 字节`,
      );
    }

    const { encrypted, iv, kmsKeyVersion } = this.cryptoSvc.encrypt(value);

    // upsert + 取出 user_id（由 employeeSlug 反查 binding）
    const binding = await this.prisma.employeeSlugBinding.findUnique({
      where: { employeeSlug },
    });
    if (!binding) {
      return this.error('internal_error', 'employee slug binding 缺失（异常）');
    }

    await this.prisma.internalAppEnvVar.upsert({
      where: { uq_env_per_app: { appId, key } },
      create: {
        appId,
        key,
        valueEncrypted: new Uint8Array(encrypted),
        valueIv: new Uint8Array(iv),
        kmsKeyVersion,
        organizationId,
        createdById: binding.userId,
      },
      update: {
        valueEncrypted: new Uint8Array(encrypted),
        valueIv: new Uint8Array(iv),
        kmsKeyVersion,
      },
    });

    this.logger.log(`env set appId=${appId} key=${key}`); // 不记 value

    await this.eventsSvc.emit({
      eventType: EventType.APP_ENV_SET,
      actorRole: 'OWNER',
      organizationId,
      appId,
      employeeSlug,
      actorId: binding.userId,
      payload: { key }, // 不写 value（含加密版本也不写，避免事件流泄漏路径）
    });

    return {
      ok: true as const,
      data: {
        key,
        appliedAt: new Date().toISOString(),
        // rollover 在 Phase 0 中期接入；当前返回 null 占位
        rolloverDeploymentId: null,
      },
    };
  }

  @SkipAssertAccess('deleteMany 用 (appId, key) 复合过滤；appId 已由 env() 上层用 (employeeSlug, appSlug) 复合主键预先 findUnique 校验为属于当前员工，无 IDOR 风险')
  private async envUnset(
    appId: string,
    organizationId: string,
    employeeSlug: string,
    key: string | undefined,
  ) {
    if (!key) return this.error('invalid_request', 'unset 必须传 key');
    const result = await this.prisma.internalAppEnvVar.deleteMany({
      where: { appId, key },
    });
    if (result.count === 0) {
      return this.error('env_not_found', `env key "${key}" 不存在`);
    }
    this.logger.log(`env unset appId=${appId} key=${key}`);

    const binding = await this.prisma.employeeSlugBinding.findUnique({
      where: { employeeSlug },
      select: { userId: true },
    });
    if (binding) {
      await this.eventsSvc.emit({
        eventType: EventType.APP_ENV_UNSET,
        actorRole: 'OWNER',
        organizationId,
        appId,
        employeeSlug,
        actorId: binding.userId,
        payload: { key },
      });
    }

    return {
      ok: true as const,
      data: { key, appliedAt: new Date().toISOString(), rolloverDeploymentId: null },
    };
  }

  /**
   * destroy（PRD F2.4）：停容器 + 移 Caddy 路由 + DB 状态机推进 + 数据保留 30 天
   *
   * Phase 0 顺序：先停容器/路由（外部不可访问），再标 DB 状态——
   * 反过来如果先标 DESTROYED 但容器停失败，DB 与运行态不一致还更难排查。
   * Gitea 仓库归档放 Phase 1（需 INTERNAL_APP_GITEA_API_TOKEN 已配）。
   */
  private async destroy(
    employeeSlug: string,
    input: DestroyInput,
  ): Promise<McpResult<DestroyOutput>> {
    if (input.confirm !== true) {
      return this.error('invalid_request', 'destroy 需要 confirm: true 显式确认');
    }
    if (!input.appSlug) {
      return this.error('invalid_request', '缺 appSlug');
    }

    const app = await this.prisma.internalApp.findUnique({
      where: {
        uq_app_per_employee: { employeeSlug, appSlug: input.appSlug },
      },
    });

    if (!app) {
      return this.error('app_not_found', `未找到 app "${input.appSlug}"`);
    }
    if (
      app.status === 'DESTROYED' ||
      app.status === 'DISABLED_ARCHIVED' ||
      app.status === 'PURGED'
    ) {
      return this.error(
        'app_destroyed',
        `app 已是 ${app.status} 状态，无需重复销毁`,
      );
    }

    // 1. 先停容器 + 删 Caddy 路由（幂等，容器不存在不报错）
    const teardown = await this.containerHost.destroyContainer(
      employeeSlug,
      input.appSlug,
    );
    if (!teardown.ok) {
      // 容器侧失败不写 DB，让员工/IT 看到真错误重试
      return this.error(teardown.code, teardown.message, {
        hint: 'app 状态保持原样，可联系 IT 处理或稍后重试',
      });
    }

    // 2. 归档 Gitea 仓库（30 天保留期内可读不可写）
    // 容错：归档失败不阻塞 DB 状态机推进——容器已停 + Caddy 已撤，员工已经看不到 app；
    // Gitea 残留只是 IT 清理负担，不是业务阻塞。
    const archive = await this.giteaSvc.archiveRepo({
      employeeSlug,
      appSlug: input.appSlug,
    });
    if (!archive.ok) {
      this.logger.warn(
        `gitea archive 失败但继续推进 destroy: ${archive.error.code} ${archive.error.message}`,
      );
    }

    // 3. 推进 DB 状态机
    const now = new Date();
    const retentionUntil = new Date(
      now.getTime() + RETENTION_DAYS * 24 * 3600 * 1000,
    );

    await this.prisma.internalApp.update({
      where: { id: app.id },
      data: {
        status: 'DESTROYED',
        destroyedAt: now,
        retentionUntil,
      },
    });

    this.logger.log(
      `app destroyed appSlug=${input.appSlug} employeeSlug=${employeeSlug} retentionUntil=${retentionUntil.toISOString()}`,
    );

    const binding = await this.prisma.employeeSlugBinding.findUnique({
      where: { employeeSlug },
      select: { userId: true },
    });
    await this.eventsSvc.emit({
      eventType: EventType.APP_DESTROYED,
      actorRole: 'OWNER',
      organizationId: app.organizationId,
      appId: app.id,
      employeeSlug,
      actorId: binding?.userId ?? null,
      payload: {
        retentionUntil: retentionUntil.toISOString(),
        archiveOk: archive.ok,
      },
    });

    return {
      ok: true,
      data: {
        appSlug: input.appSlug,
        status: 'DESTROYED' as const,
        destroyedAt: now.toISOString(),
        retentionUntil: retentionUntil.toISOString(),
        restoreInstruction: `如需在 ${RETENTION_DAYS} 天内恢复，请提工单给 IT 管理员，附 app 名和销毁日期`,
      },
    };
  }

  // ==========================================================================
  // helpers
  // ==========================================================================
  private error(
    code: string,
    message: string,
    details?: Record<string, unknown>,
  ): McpResult<never> {
    return {
      ok: false,
      error: {
        code,
        message,
        details,
        onboardUrl: code.endsWith('_token') ? ONBOARD_URL : undefined,
      },
    };
  }
}
