/**
 * InternalAppMcpToolsService Unit Tests
 *
 * 覆盖 07-api.md §3 MCP 工具关键分支：
 * - tools/list 工具清单完整性
 * - callTool 路由分发 + 未知工具
 * - deploy_prepare 的 runtime 判别（node / static / 5 种 unsupported_runtime 友好 hint）
 * - deploy_prepare slug 校验失败 → reserved_slug / invalid_slug
 * - list_apps 路径 includeDestroyed 透传
 *
 * 纯逻辑 + mock platformSvc，符合 CLAUDE.md §测试"复杂纯计算逻辑可写单元测试"例外。
 */

import { InternalAppMcpToolsService } from '@/modules/internal-app-platform/services/mcp-tools.service';
import { InternalAppPlatformService } from '@/modules/internal-app-platform/internal-app-platform.service';
import { InternalAppSlugService } from '@/modules/internal-app-platform/services/slug.service';
import { InternalAppEnvCryptoService } from '@/modules/internal-app-platform/services/env-crypto.service';
import { GiteaClientService } from '@/modules/internal-app-platform/services/gitea-client.service';
import { ContainerHostService } from '@/modules/internal-app-platform/services/container-host.service';
import { InternalAppEventsService } from '@/modules/internal-app-platform/services/events.service';
import type { PrismaService } from '@/core/database/prisma/prisma.service';

describe('InternalAppMcpToolsService', () => {
  let svc: InternalAppMcpToolsService;
  let platformSvc: jest.Mocked<InternalAppPlatformService>;
  const slugSvc = new InternalAppSlugService();
  let prisma: jest.Mocked<PrismaService>;
  let cryptoSvc: jest.Mocked<InternalAppEnvCryptoService>;
  let giteaSvc: jest.Mocked<GiteaClientService>;
  let containerHost: jest.Mocked<ContainerHostService>;
  let eventsSvc: jest.Mocked<InternalAppEventsService>;

  beforeEach(() => {
    platformSvc = {
      listMyApps: jest.fn().mockResolvedValue([]),
    } as unknown as jest.Mocked<InternalAppPlatformService>;
    prisma = {
      internalApp: {
        findUnique: jest.fn(),
        update: jest.fn(),
        // deploy_prepare happy path 末尾 upsert internal_apps 行（webhook 触发先决）。
        upsert: jest
          .fn()
          .mockResolvedValue({ id: '00000000-0000-0000-0000-000000000001' }),
      },
      internalAppEnvVar: {
        findMany: jest.fn(),
        findUnique: jest.fn(),
        upsert: jest.fn(),
        deleteMany: jest.fn(),
      },
      // deploy_prepare 反查 binding 拿 userId/organizationId 给 upsert。默认返一条有效绑定，
      // 测 binding 缺失的用例显式覆盖 mock 即可。
      employeeSlugBinding: {
        findUnique: jest.fn().mockResolvedValue({
          userId: '00000000-0000-0000-0000-000000000010',
          organizationId: '00000000-0000-0000-0000-000000000020',
        }),
      },
      // logs() container_not_found 分支会查最近 deploy_failed 事件回填诊断信息（见
      // mcp-tools.service.ts:398）。默认无事件 → null → 走"无 lastDeployFailure"分支。
      internalAppEvent: { findFirst: jest.fn().mockResolvedValue(null) },
    } as unknown as jest.Mocked<PrismaService>;
    cryptoSvc = {
      encrypt: jest.fn(),
      decrypt: jest.fn(),
      maskValue: jest.fn(),
    } as unknown as jest.Mocked<InternalAppEnvCryptoService>;
    giteaSvc = {
      createRepo: jest.fn(),
      getRepo: jest.fn().mockResolvedValue({ ok: true, exists: false }),
      issuePushCredential: jest.fn().mockReturnValue({
        ok: true,
        credential: {
          token: 'test-push-token',
          expiresAt: new Date(Date.now() + 5 * 60 * 1000),
          isEphemeral: false,
        },
      }),
      archiveRepo: jest.fn().mockResolvedValue({ ok: true, alreadyArchived: false }),
      // deploy_prepare 每次都会 ensureWebhook（幂等装 push hook，防 org-level URL 漂移）
      ensureWebhook: jest.fn().mockResolvedValue({ ok: true, created: false }),
    } as unknown as jest.Mocked<GiteaClientService>;
    containerHost = {
      getContainerLogs: jest.fn(),
      destroyContainer: jest.fn().mockResolvedValue({ ok: true }),
      buildContainerName: jest.fn(
        (e: string, a: string) => `ffoa-app-${e}-${a}`,
      ),
    } as unknown as jest.Mocked<ContainerHostService>;
    eventsSvc = {
      emit: jest.fn().mockResolvedValue(undefined),
    } as unknown as jest.Mocked<InternalAppEventsService>;
    svc = new InternalAppMcpToolsService(
      platformSvc,
      slugSvc,
      prisma,
      cryptoSvc,
      giteaSvc,
      containerHost,
      eventsSvc,
    );
  });

  // listTools() 已删除：SDK 接管 tools/list 路由，控制器用 server.registerTool 逐个注册。
  // 工具清单完整性由 mcp.controller.integration.test.ts 的 tools/list spec 覆盖（真实 SDK 协议）。

  describe('callTool 路由', () => {
    it('未知工具返回 unknown_tool 错误', async () => {
      const result = await svc.callTool('zhang-san', 'no_such_tool' as never, {});
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('unknown_tool');
    });

    it('list_apps 路由到 platformSvc.listMyApps', async () => {
      await svc.callTool('zhang-san', 'list_apps', { includeDestroyed: true });
      expect(platformSvc.listMyApps).toHaveBeenCalledWith('zhang-san', true);
    });

    it('list_apps 默认 includeDestroyed=false', async () => {
      await svc.callTool('zhang-san', 'list_apps', {});
      expect(platformSvc.listMyApps).toHaveBeenCalledWith('zhang-san', false);
    });
  });

  describe('deploy_prepare runtime 判别', () => {
    const baseSlug = { appSlug: 'birthday-reminder' };

    beforeEach(() => {
      // 成功路径都假设 Gitea repo 不存在 + createRepo 成功
      (giteaSvc.getRepo as jest.Mock).mockResolvedValue({
        ok: true,
        exists: false,
      });
      (giteaSvc.createRepo as jest.Mock).mockResolvedValue({
        ok: true,
        repo: {
          id: 1,
          fullName: 'FFAIApps/zhang-san-birthday-reminder',
          cloneUrl: 'http://gitea/FFAIApps/zhang-san-birthday-reminder.git',
          sshUrl: 'ssh://gitea/FFAIApps/zhang-san-birthday-reminder.git',
        },
      });
    });

    it('node runtime: package.json + start script', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        ...baseSlug,
        detected: { hasPackageJson: true, hasStartScript: true, hasIndexHtml: false },
      });
      expect(result.ok).toBe(true);
      if (result.ok)
        expect((result.data as { runtime: string }).runtime).toBe('node');
    });

    it('static runtime: 仅 index.html', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        ...baseSlug,
        detected: { hasPackageJson: false, hasStartScript: false, hasIndexHtml: true },
      });
      expect(result.ok).toBe(true);
      if (result.ok)
        expect((result.data as { runtime: string }).runtime).toBe('static');
    });

    it('unsupported_runtime: Dockerfile 显式 hint', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        ...baseSlug,
        detected: {
          hasPackageJson: false,
          hasStartScript: false,
          hasIndexHtml: false,
          hasDockerfile: true,
        },
      });
      expect(result.ok).toBe(false);
      if (!result.ok) {
        expect(result.error.code).toBe('unsupported_runtime');
        expect(result.error.details?.hint).toContain('Dockerfile');
      }
    });

    it('unsupported_runtime: Python (requirements.txt)', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        ...baseSlug,
        detected: {
          hasPackageJson: false,
          hasStartScript: false,
          hasIndexHtml: false,
          hasRequirementsTxt: true,
        },
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.details?.hint).toContain('Python');
    });

    it('unsupported_runtime: Go (go.mod)', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        ...baseSlug,
        detected: {
          hasPackageJson: false,
          hasStartScript: false,
          hasIndexHtml: false,
          hasGoMod: true,
        },
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.details?.hint).toContain('Go');
    });

    it('unsupported_runtime: Java (pom.xml)', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        ...baseSlug,
        detected: {
          hasPackageJson: false,
          hasStartScript: false,
          hasIndexHtml: false,
          hasPomXml: true,
        },
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.details?.hint).toContain('Java');
    });

    it('unsupported_runtime: package.json 缺 start script → 友好提示加 start', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        ...baseSlug,
        detected: {
          hasPackageJson: true,
          hasStartScript: false,
          hasIndexHtml: false,
        },
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.details?.hint).toContain('start script');
    });
  });

  describe('deploy_prepare Gitea 错误透传', () => {
    const validInput = {
      appSlug: 'birthday-reminder',
      detected: { hasPackageJson: true, hasStartScript: true, hasIndexHtml: false },
    };

    it('Gitea token 缺失 → gitea_token_missing 错误码', async () => {
      (giteaSvc.getRepo as jest.Mock).mockResolvedValue({
        ok: false,
        error: { code: 'gitea_token_missing', message: 'token 未配置' },
      });
      const result = await svc.callTool('zhang-san', 'deploy_prepare', validInput);
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('gitea_token_missing');
    });

    it('Gitea scope 不足 → gitea_token_insufficient_scope 错误码', async () => {
      (giteaSvc.getRepo as jest.Mock).mockResolvedValue({
        ok: false,
        error: {
          code: 'gitea_token_insufficient_scope',
          message: '缺 write:organization',
          required: ['write:organization'],
        },
      });
      const result = await svc.callTool('zhang-san', 'deploy_prepare', validInput);
      expect(result.ok).toBe(false);
      if (!result.ok) {
        expect(result.error.code).toBe('gitea_token_insufficient_scope');
        expect((result.error.details as { required?: string[] }).required).toContain(
          'write:organization',
        );
      }
    });

    it('Gitea 不可达 → gitea_unreachable 错误码', async () => {
      (giteaSvc.getRepo as jest.Mock).mockResolvedValue({
        ok: false,
        error: {
          code: 'gitea_unreachable',
          message: 'Gitea API 不可达',
          cause: 'ECONNREFUSED',
        },
      });
      const result = await svc.callTool('zhang-san', 'deploy_prepare', validInput);
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('gitea_unreachable');
    });

    it('repo 已存在 → isFirstDeploy=false', async () => {
      (giteaSvc.getRepo as jest.Mock).mockResolvedValue({
        ok: true,
        exists: true,
        repo: {
          id: 99,
          fullName: 'FFAIApps/zhang-san-birthday-reminder',
          cloneUrl: 'http://gitea/FFAIApps/zhang-san-birthday-reminder.git',
          sshUrl: 'ssh://gitea/FFAIApps/zhang-san-birthday-reminder.git',
        },
      });
      const result = await svc.callTool('zhang-san', 'deploy_prepare', validInput);
      expect(result.ok).toBe(true);
      if (result.ok) {
        expect((result.data as { isFirstDeploy: boolean }).isFirstDeploy).toBe(false);
      }
      // createRepo 不应被调用
      expect(giteaSvc.createRepo).not.toHaveBeenCalled();
    });
  });

  describe('deploy_prepare slug 校验', () => {
    it('reserved slug → reserved_slug 错误码', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        appSlug: 'admin',
        detected: { hasPackageJson: true, hasStartScript: true, hasIndexHtml: false },
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('reserved_slug');
    });

    it('invalid slug 格式 → invalid_slug 错误码', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {
        appSlug: 'Birthday-Reminder!',
        detected: { hasPackageJson: true, hasStartScript: true, hasIndexHtml: false },
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('invalid_slug');
    });

    it('缺 appSlug → invalid_slug 错误码', async () => {
      const result = await svc.callTool('zhang-san', 'deploy_prepare', {});
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('invalid_slug');
    });
  });

  describe('destroy', () => {
    it('app 不存在 → app_not_found', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue(null);
      const result = await svc.callTool('zhang-san', 'destroy', {
        appSlug: 'birthday-reminder',
        confirm: true,
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('app_not_found');
    });

    it('已 DESTROYED → app_destroyed', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'app-1',
        status: 'DESTROYED',
      });
      const result = await svc.callTool('zhang-san', 'destroy', {
        appSlug: 'birthday-reminder',
        confirm: true,
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('app_destroyed');
    });

    it('HEALTHY 成功销毁 → 先停容器、再写 destroyedAt + retentionUntil(=now+30d)', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'app-1',
        status: 'HEALTHY',
      });
      (prisma.internalApp.update as jest.Mock).mockResolvedValue({});
      const result = await svc.callTool('zhang-san', 'destroy', {
        appSlug: 'birthday-reminder',
        confirm: true,
      });
      expect(result.ok).toBe(true);
      expect(containerHost.destroyContainer).toHaveBeenCalledWith(
        'zhang-san',
        'birthday-reminder',
      );
      const updateCall = (prisma.internalApp.update as jest.Mock).mock.calls[0][0];
      expect(updateCall.data.status).toBe('DESTROYED');
      expect(updateCall.data.destroyedAt).toBeInstanceOf(Date);
      const retention = updateCall.data.retentionUntil as Date;
      const diffDays = (retention.getTime() - Date.now()) / (24 * 3600 * 1000);
      expect(diffDays).toBeGreaterThan(29);
      expect(diffDays).toBeLessThan(31);
    });

    it('成功销毁同时归档 Gitea 仓库', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'app-1',
        status: 'HEALTHY',
      });
      (prisma.internalApp.update as jest.Mock).mockResolvedValue({});
      await svc.callTool('zhang-san', 'destroy', {
        appSlug: 'birthday-reminder',
        confirm: true,
      });
      expect(giteaSvc.archiveRepo).toHaveBeenCalledWith({
        employeeSlug: 'zhang-san',
        appSlug: 'birthday-reminder',
      });
    });

    it('Gitea 归档失败不阻塞 destroy（容错路径）', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'app-1',
        status: 'HEALTHY',
      });
      (prisma.internalApp.update as jest.Mock).mockResolvedValue({});
      (giteaSvc.archiveRepo as jest.Mock).mockResolvedValueOnce({
        ok: false,
        error: { code: 'gitea_unreachable', message: 'Gitea API 不可达' },
      });
      const result = await svc.callTool('zhang-san', 'destroy', {
        appSlug: 'birthday-reminder',
        confirm: true,
      });
      // 容器已停 + Caddy 已撤，DB 仍标 DESTROYED，员工已看不到 app
      expect(result.ok).toBe(true);
      expect(prisma.internalApp.update).toHaveBeenCalled();
    });

    it('容器停止失败 → 不更新 DB，返回真错误码', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'app-1',
        status: 'HEALTHY',
      });
      (containerHost.destroyContainer as jest.Mock).mockResolvedValue({
        ok: false,
        code: 'caddy_reload_failed',
        message: 'Caddy reload 失败',
      });
      const result = await svc.callTool('zhang-san', 'destroy', {
        appSlug: 'birthday-reminder',
        confirm: true,
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('caddy_reload_failed');
      expect(prisma.internalApp.update).not.toHaveBeenCalled();
    });

    it('confirm !== true → invalid_request', async () => {
      const result = await svc.callTool('zhang-san', 'destroy', {
        appSlug: 'foo',
        confirm: false as unknown as true,
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('invalid_request');
    });
  });

  describe('env 校验', () => {
    beforeEach(() => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'app-1',
        organizationId: 'org-1',
      });
    });

    it('reserved_env_prefix: FFOA_xxx 被拒', async () => {
      const result = await svc.callTool('zhang-san', 'env', {
        appSlug: 'foo',
        action: 'set',
        key: 'FFOA_HACK',
        value: 'x',
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('reserved_env_prefix');
    });

    it('reserved_env_prefix: PLATFORM_xxx 被拒', async () => {
      const result = await svc.callTool('zhang-san', 'env', {
        appSlug: 'foo',
        action: 'set',
        key: 'PLATFORM_FOO',
        value: 'x',
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('reserved_env_prefix');
    });

    it('invalid_env_key: 小写开头被拒', async () => {
      const result = await svc.callTool('zhang-san', 'env', {
        appSlug: 'foo',
        action: 'set',
        key: 'lowercase',
        value: 'x',
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('invalid_env_key');
    });

    it('env_value_too_large: > 4KB 被拒', async () => {
      const result = await svc.callTool('zhang-san', 'env', {
        appSlug: 'foo',
        action: 'set',
        key: 'OK_KEY',
        value: 'x'.repeat(5000),
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('env_value_too_large');
    });

    it('get key 不存在 → env_not_found', async () => {
      (prisma.internalAppEnvVar.findUnique as jest.Mock).mockResolvedValue(null);
      const result = await svc.callTool('zhang-san', 'env', {
        appSlug: 'foo',
        action: 'get',
        key: 'OPENAI_KEY',
      });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('env_not_found');
    });
  });

  describe('logs', () => {
    it('缺 appSlug → invalid_request', async () => {
      const result = await svc.callTool('zhang-san', 'logs', {});
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('invalid_request');
    });

    it('app 不存在 → app_not_found', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue(null);
      const result = await svc.callTool('zhang-san', 'logs', { appSlug: 'foo' });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('app_not_found');
    });

    it('app 已 DESTROYED → app_destroyed', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'a', appSlug: 'foo', runtime: 'node', status: 'DESTROYED',
      });
      const result = await svc.callTool('zhang-san', 'logs', { appSlug: 'foo' });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('app_destroyed');
    });

    it('happy path：调 ContainerHostService 取日志并返回结构化结果', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'a', appSlug: 'hello', runtime: 'node', status: 'HEALTHY',
      });
      containerHost.getContainerLogs.mockResolvedValue({
        stdout: 'listening on 3000\nreq /test 200\n',
        truncatedByLineCap: false,
      });
      const result = await svc.callTool('zhang-san', 'logs', {
        appSlug: 'hello',
        lines: 50,
      });
      expect(result.ok).toBe(true);
      if (result.ok) {
        const data = result.data as { appSlug: string; logs: string; runtime: string };
        expect(data.appSlug).toBe('hello');
        expect(data.runtime).toBe('node');
        expect(data.logs).toContain('listening on 3000');
      }
      expect(containerHost.getContainerLogs).toHaveBeenCalledWith(
        'ffoa-app-zhang-san-hello',
        50,
      );
    });

    it('容器不存在 → container_not_found（友好提示，不裸抛 docker 错）', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'a', appSlug: 'hello', runtime: 'node', status: 'PENDING',
      });
      containerHost.getContainerLogs.mockRejectedValue(new Error('container_not_found'));
      const result = await svc.callTool('zhang-san', 'logs', { appSlug: 'hello' });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('container_not_found');
    });

    it('宿主不可达 → host_unreachable', async () => {
      (prisma.internalApp.findUnique as jest.Mock).mockResolvedValue({
        id: 'a', appSlug: 'hello', runtime: 'node', status: 'HEALTHY',
      });
      containerHost.getContainerLogs.mockRejectedValue(new Error('host_unreachable'));
      const result = await svc.callTool('zhang-san', 'logs', { appSlug: 'hello' });
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.error.code).toBe('host_unreachable');
    });
  });

  describe('占位工具', () => {
    it('env / destroy 已实现但缺参数 → invalid_request', async () => {
      for (const tool of ['env', 'destroy'] as const) {
        const result = await svc.callTool('zhang-san', tool, {});
        expect(result.ok).toBe(false);
        if (!result.ok) {
          expect(result.error.code).toBe('invalid_request');
        }
      }
    });
  });
});
