/**
 * InternalAppMcpToolsService L1 集成测试
 *
 * 单测覆盖 5 工具的路由 + 校验，但 mock 了 prisma 与 containerHost。
 * 本测试用真实 DB 验证：
 * - list_apps 真按 employeeSlug 过滤，含 includeDestroyed 行为
 * - env set/get/list/unset 走 AES-GCM 真加解密 + DB 写入
 * - reserved prefix / 不合规 key 真返结构化错误
 * - destroy 状态机推进（HEALTHY → DESTROYED + retentionUntil = now+30d）
 *
 * containerHost.runDeployScript / destroyContainer / getContainerLogs 仍 mock
 * （不真 docker），其余路径全真 DB。
 */

import { INestApplication } from '@nestjs/common';
import { createTestApp } from '../../helpers/app.helper';
import { InternalAppMcpToolsService } from '@/modules/internal-app-platform/services/mcp-tools.service';
import { ContainerHostService } from '@/modules/internal-app-platform/services/container-host.service';
import { GiteaClientService } from '@/modules/internal-app-platform/services/gitea-client.service';
import { PrismaService } from '@/core/database/prisma/prisma.service';

const T = 't-iap-mcp-';
const ORG_CODE = `${T}org`;

describe('InternalAppMcpToolsService (L1)', () => {
  let app: INestApplication;
  let svc: InternalAppMcpToolsService;
  let prisma: PrismaService;
  let host: ContainerHostService;
  let userId: string;
  let orgId: string;
  let employeeSlug: string;
  let helloAppId: string;
  let destroyContainerSpy: jest.SpyInstance;
  let getLogsSpy: jest.SpyInstance;

  beforeAll(async () => {
    process.env.INTERNAL_APP_ENV_MASTER_KEY =
      process.env.INTERNAL_APP_ENV_MASTER_KEY ??
      'test_master_key_for_l1_integration_only_32+';
    app = await createTestApp();
    svc = app.get(InternalAppMcpToolsService);
    prisma = app.get(PrismaService);
    host = app.get(ContainerHostService);

    const org = await prisma.organization.upsert({
      where: { code: ORG_CODE },
      create: {
        code: ORG_CODE,
        name: `${T}org`,
        status: 'ACTIVE',
        isActive: true,
        settings: {},
        financialConfig: {},
        complianceConfig: {},
        order: 0,
        metadata: {},
      } as any,
      update: {},
    });
    orgId = org.id;

    const user = await prisma.user.create({
      data: {
        username: `${T}user`,
        email: `${T}user@test.local`,
        passwordHash: 'PLACEHOLDER',
        displayName: `${T}user`,
        status: 'ACTIVE',
        source: 'LOCAL',
        tenantId: 'default',
      },
    });
    userId = user.id;
    employeeSlug = `${T}emp`;
    await prisma.employeeSlugBinding.create({
      data: {
        userId,
        employeeSlug,
        sourceMailNickname: `${T}emp`,
        organizationId: orgId,
        createdById: userId,
      },
    });
  });

  afterAll(async () => {
    await prisma.internalAppEnvVar.deleteMany({ where: { app: { employeeSlug } } });
    await prisma.internalAppDeployment.deleteMany({ where: { app: { employeeSlug } } });
    await prisma.internalApp.deleteMany({ where: { employeeSlug } });
    await prisma.internalAppEmployeeToken.deleteMany({ where: { employeeSlug } });
    await prisma.employeeSlugBinding.deleteMany({ where: { employeeSlug } });
    await prisma.user.deleteMany({ where: { id: userId } });
    await prisma.organization.deleteMany({ where: { code: ORG_CODE } });
    await app.close();
  });

  beforeEach(async () => {
    // 每个 it 干净 hello app（重建）
    await prisma.internalApp.deleteMany({ where: { employeeSlug } });
    const helloApp = await prisma.internalApp.create({
      data: {
        employeeSlug,
        appSlug: 'hello',
        displayName: 'Hello',
        runtime: 'node',
        status: 'HEALTHY',
        url: `http://${employeeSlug}-hello.test.local`,
        giteaRepoFullName: `FFAIApps/${employeeSlug}-hello`,
        organizationId: orgId,
        createdById: userId,
        lastDeployedAt: new Date(),
      },
    });
    helloAppId = helloApp.id;
    destroyContainerSpy = jest
      .spyOn(host, 'destroyContainer')
      .mockResolvedValue({ ok: true });
    getLogsSpy = jest
      .spyOn(host, 'getContainerLogs')
      .mockResolvedValue({ stdout: 'mock log line\n', truncatedByLineCap: false });
  });

  afterEach(() => {
    destroyContainerSpy.mockRestore();
    getLogsSpy.mockRestore();
  });

  describe('list_apps', () => {
    it('默认隐藏 DESTROYED，HEALTHY 可见', async () => {
      const r = await svc.callTool(employeeSlug, 'list_apps', {});
      expect(r.ok).toBe(true);
      if (r.ok) {
        const apps = (r.data as { apps: Array<{ appSlug: string }> }).apps;
        expect(apps.map((a) => a.appSlug)).toContain('hello');
      }
    });

    it('includeDestroyed=true: DESTROYED 也返回', async () => {
      await prisma.internalApp.update({
        where: { id: helloAppId },
        data: { status: 'DESTROYED', destroyedAt: new Date() },
      });
      const r = await svc.callTool(employeeSlug, 'list_apps', { includeDestroyed: true });
      expect(r.ok).toBe(true);
      if (r.ok) {
        const apps = (r.data as { apps: Array<{ status: string }> }).apps;
        expect(apps[0]?.status).toBe('DESTROYED');
      }
    });
  });

  describe('env set/get/list/unset', () => {
    it('set + get 走 AES-GCM 真加解密，明文应能还原', async () => {
      const setResult = await svc.callTool(employeeSlug, 'env', {
        appSlug: 'hello',
        action: 'set',
        key: 'OPENAI_KEY',
        value: 'sk-test-very-secret-12345',
      });
      expect(setResult.ok).toBe(true);

      const rows = await prisma.internalAppEnvVar.findMany({
        where: { app: { employeeSlug } },
      });
      expect(rows).toHaveLength(1);
      // 验证：DB 里存的不是明文
      const encryptedBuf = Buffer.from(rows[0].valueEncrypted);
      expect(encryptedBuf.toString('utf8')).not.toContain('sk-test');

      const getResult = await svc.callTool(employeeSlug, 'env', {
        appSlug: 'hello',
        action: 'get',
        key: 'OPENAI_KEY',
      });
      expect(getResult.ok).toBe(true);
      if (getResult.ok) {
        const { value } = getResult.data as { key: string; value: string };
        expect(value).toBe('sk-test-very-secret-12345');
      }
    });

    it('list 返回 masked preview，不泄露明文', async () => {
      await svc.callTool(employeeSlug, 'env', {
        appSlug: 'hello',
        action: 'set',
        key: 'SECRET_X',
        value: 'real-secret',
      });
      const r = await svc.callTool(employeeSlug, 'env', {
        appSlug: 'hello',
        action: 'list',
      });
      expect(r.ok).toBe(true);
      if (r.ok) {
        const { envVars } = r.data as {
          envVars: Array<{ key: string; valuePreview: string }>;
        };
        expect(envVars[0].key).toBe('SECRET_X');
        expect(envVars[0].valuePreview).toBe('****');
        expect(envVars[0].valuePreview).not.toContain('real');
      }
    });

    it('reserved prefix FFOA_ 拒绝', async () => {
      const r = await svc.callTool(employeeSlug, 'env', {
        appSlug: 'hello',
        action: 'set',
        key: 'FFOA_INTERNAL',
        value: 'x',
      });
      expect(r.ok).toBe(false);
      if (!r.ok) expect(r.error.code).toBe('reserved_env_prefix');
    });

    it('unset 删除真行', async () => {
      await svc.callTool(employeeSlug, 'env', {
        appSlug: 'hello',
        action: 'set',
        key: 'TO_DEL',
        value: 'x',
      });
      const r = await svc.callTool(employeeSlug, 'env', {
        appSlug: 'hello',
        action: 'unset',
        key: 'TO_DEL',
      });
      expect(r.ok).toBe(true);
      const rows = await prisma.internalAppEnvVar.findMany({
        where: { app: { employeeSlug }, key: 'TO_DEL' },
      });
      expect(rows).toHaveLength(0);
    });
  });

  describe('destroy', () => {
    it('HEALTHY → DESTROYED + retentionUntil=now+30d，先调 destroyContainer 再写 DB', async () => {
      const r = await svc.callTool(employeeSlug, 'destroy', {
        appSlug: 'hello',
        confirm: true,
      });
      expect(r.ok).toBe(true);
      expect(destroyContainerSpy).toHaveBeenCalledWith(employeeSlug, 'hello');

      const app2 = await prisma.internalApp.findUnique({
        where: { id: helloAppId },
      });
      expect(app2?.status).toBe('DESTROYED');
      expect(app2?.destroyedAt).toBeInstanceOf(Date);
      const diffDays =
        (app2!.retentionUntil!.getTime() - Date.now()) / (24 * 3600 * 1000);
      expect(diffDays).toBeGreaterThan(29);
      expect(diffDays).toBeLessThan(31);
    });

    it('容器停止失败：不更新 DB，返回真错误码', async () => {
      destroyContainerSpy.mockResolvedValueOnce({
        ok: false,
        code: 'caddy_reload_failed',
        message: 'Caddy reload 失败',
      });
      const r = await svc.callTool(employeeSlug, 'destroy', {
        appSlug: 'hello',
        confirm: true,
      });
      expect(r.ok).toBe(false);
      if (!r.ok) expect(r.error.code).toBe('caddy_reload_failed');
      const app2 = await prisma.internalApp.findUnique({
        where: { id: helloAppId },
      });
      expect(app2?.status).toBe('HEALTHY');
    });
  });

  describe('logs', () => {
    it('HEALTHY app: 真调 host.getContainerLogs', async () => {
      const r = await svc.callTool(employeeSlug, 'logs', {
        appSlug: 'hello',
        lines: 20,
      });
      expect(r.ok).toBe(true);
      expect(getLogsSpy).toHaveBeenCalledWith(
        `ffoa-app-${employeeSlug}-hello`,
        20,
      );
    });

    it('DESTROYED app: app_destroyed 拦截，不调 docker logs', async () => {
      await prisma.internalApp.update({
        where: { id: helloAppId },
        data: { status: 'DESTROYED', destroyedAt: new Date() },
      });
      const r = await svc.callTool(employeeSlug, 'logs', { appSlug: 'hello' });
      expect(r.ok).toBe(false);
      if (!r.ok) expect(r.error.code).toBe('app_destroyed');
      expect(getLogsSpy).not.toHaveBeenCalled();
    });

    // 容器不存在时自动回填 deploy_failed 事件（防员工卡死 "container doesn't exist"）
    // 详见 .learnings/2026-05-19-logs-tool-needs-build-stage-fallback.md
    it('container_not_found + 有 deploy_failed 事件 → 回填 lastDeployFailure 到 details', async () => {
      // mock container 不存在
      getLogsSpy.mockRejectedValue(new Error('container_not_found'));
      // 种一条 deploy_failed 事件
      await prisma.internalAppEvent.create({
        data: {
          appId: helloAppId,
          employeeSlug,
          actorRole: 'SYSTEM',
          eventType: 'app.deploy_failed',
          outcome: 'FAIL',
          errorCode: 'npm_install_failed',
          payload: {
            commitSha: 'abc1234',
            message: 'npm ERR! missing dep "foo"',
          },
          organizationId: orgId,
        },
      });

      const r = await svc.callTool(employeeSlug, 'logs', { appSlug: 'hello' });
      expect(r.ok).toBe(false);
      if (!r.ok) {
        expect(r.error.code).toBe('container_not_found');
        const details = r.error.details as {
          lastDeployFailure: {
            errorCode: string;
            message: string;
            commitSha: string;
            failedAt: string;
          } | null;
        };
        expect(details.lastDeployFailure).not.toBeNull();
        expect(details.lastDeployFailure?.errorCode).toBe('npm_install_failed');
        expect(details.lastDeployFailure?.message).toContain('npm ERR!');
        expect(details.lastDeployFailure?.commitSha).toBe('abc1234');
      }
    });

    it('container_not_found + 无 deploy_failed 事件 → lastDeployFailure=null（首次部署/构建中）', async () => {
      getLogsSpy.mockRejectedValue(new Error('container_not_found'));
      // 不种 deploy_failed 事件（默认 beforeEach 重建 hello 时表里没相关事件）
      const r = await svc.callTool(employeeSlug, 'logs', { appSlug: 'hello' });
      expect(r.ok).toBe(false);
      if (!r.ok) {
        expect(r.error.code).toBe('container_not_found');
        const details = r.error.details as { lastDeployFailure: unknown };
        expect(details.lastDeployFailure).toBeNull();
      }
    });
  });

  describe('deploy_prepare happy-path upsert', () => {
    // PR #434 ai-review test-coverage risk：deploy_prepare 同事务 upsert internal_apps
    // 是 webhook 触发先决条件，必须直测两次 deploy 的 upsert 行为（首次 CREATE / 增量 UPDATE）。
    let giteaSvc: GiteaClientService;
    let createRepoSpy: jest.SpyInstance;
    let getRepoSpy: jest.SpyInstance;
    let ensureWebhookSpy: jest.SpyInstance;
    let issuePushCredentialSpy: jest.SpyInstance;
    const happySlug = 'happy';
    const fullName = (slug: string) => `FFAIApps/${employeeSlug}-${slug}`;

    beforeAll(() => {
      giteaSvc = app.get(GiteaClientService);
    });

    beforeEach(async () => {
      // 清掉上层 beforeEach 建的 hello + 任何残留
      await prisma.internalApp.deleteMany({ where: { employeeSlug } });
      createRepoSpy = jest.spyOn(giteaSvc, 'createRepo').mockResolvedValue({
        ok: true,
        repo: {
          id: 1,
          fullName: fullName(happySlug),
          cloneUrl: `http://gitea/${fullName(happySlug)}.git`,
          sshUrl: `ssh://gitea/${fullName(happySlug)}.git`,
        },
      });
      getRepoSpy = jest
        .spyOn(giteaSvc, 'getRepo')
        .mockResolvedValue({ ok: true, exists: false });
      ensureWebhookSpy = jest
        .spyOn(giteaSvc, 'ensureWebhook')
        .mockResolvedValue({ ok: true, created: true });
      // CI 不配 INTERNAL_APP_GITEA_API_TOKEN，真实 issuePushCredential 会返
      // gitea_token_missing，测试拿不到 happy-path。spy 提供一个假 credential
      // 让被测代码继续走 upsert 路径，跟其他 mock 对齐。
      issuePushCredentialSpy = jest
        .spyOn(giteaSvc, 'issuePushCredential')
        .mockReturnValue({
          ok: true,
          credential: {
            token: 'fake-push-token-for-test',
            expiresAt: new Date(Date.now() + 5 * 60 * 1000),
            isEphemeral: false,
          },
        });
    });

    afterEach(() => {
      createRepoSpy.mockRestore();
      getRepoSpy.mockRestore();
      ensureWebhookSpy.mockRestore();
      issuePushCredentialSpy.mockRestore();
    });

    it('首次 deploy → CREATE internal_apps 行 + 触发 ensureWebhook + isFirstDeploy=true', async () => {
      const r = await svc.callTool(employeeSlug, 'deploy_prepare', {
        appSlug: happySlug,
        detected: { hasPackageJson: true, hasStartScript: true, hasIndexHtml: false },
      });
      expect(r.ok).toBe(true);
      if (!r.ok) return;
      const data = r.data as {
        appId: string;
        isFirstDeploy: boolean;
        runtime: string;
        url: string;
        postDeployHint: string;
      };
      expect(data.isFirstDeploy).toBe(true);
      expect(data.runtime).toBe('node');
      expect(data.appId).toMatch(/^[0-9a-f-]{36}$/);
      expect(data.postDeployHint).toContain('5 分钟');

      // DB 行确实落了
      const row = await prisma.internalApp.findUnique({
        where: { giteaRepoFullName: fullName(happySlug) },
      });
      expect(row).not.toBeNull();
      expect(row?.status).toBe('PENDING');
      expect(row?.runtime).toBe('node');
      expect(row?.id).toBe(data.appId);

      expect(createRepoSpy).toHaveBeenCalledTimes(1);
      expect(ensureWebhookSpy).toHaveBeenCalledWith(fullName(happySlug));
    });

    it('增量 deploy → UPDATE 不动 status/runtime/url，仅刷新 displayName，appId 稳定', async () => {
      // 先种一行（模拟首次部署后状态）
      const initial = await prisma.internalApp.create({
        data: {
          employeeSlug,
          appSlug: happySlug,
          displayName: 'Initial',
          runtime: 'node',
          status: 'HEALTHY', // 关键：webhook 已经推进到 HEALTHY
          url: `https://${employeeSlug}-${happySlug}.apps.test`,
          giteaRepoFullName: fullName(happySlug),
          organizationId: orgId,
          createdById: userId,
        },
      });
      // mock getRepo 返 exists=true（增量场景）
      getRepoSpy.mockResolvedValue({
        ok: true,
        exists: true,
        repo: {
          id: 1,
          fullName: fullName(happySlug),
          cloneUrl: `http://gitea/${fullName(happySlug)}.git`,
          sshUrl: '',
        },
      });

      const r = await svc.callTool(employeeSlug, 'deploy_prepare', {
        appSlug: happySlug,
        displayName: 'Renamed',
        detected: { hasPackageJson: true, hasStartScript: true, hasIndexHtml: false },
      });
      expect(r.ok).toBe(true);
      if (!r.ok) return;
      const data = r.data as { appId: string; isFirstDeploy: boolean };
      expect(data.isFirstDeploy).toBe(false);
      expect(data.appId).toBe(initial.id);

      const row = await prisma.internalApp.findUnique({ where: { id: initial.id } });
      expect(row?.status).toBe('HEALTHY'); // 不被增量覆盖回 PENDING
      expect(row?.displayName).toBe('Renamed'); // 仅 displayName 刷新
      expect(row?.url).toBe(initial.url); // url 不动
      expect(row?.runtime).toBe('node');

      expect(createRepoSpy).not.toHaveBeenCalled(); // 增量不再 createRepo
      expect(ensureWebhookSpy).toHaveBeenCalledWith(fullName(happySlug)); // 但仍 ensure（自愈坏仓库）
    });

    it('ensureWebhook 失败 → deploy_prepare 整体失败，DB 不留行', async () => {
      ensureWebhookSpy.mockResolvedValue({
        ok: false,
        error: {
          code: 'gitea_webhook_install_failed',
          message: '装 webhook 失败',
          fullName: fullName(happySlug),
        },
      });
      const r = await svc.callTool(employeeSlug, 'deploy_prepare', {
        appSlug: happySlug,
        detected: { hasPackageJson: true, hasStartScript: true, hasIndexHtml: false },
      });
      expect(r.ok).toBe(false);
      if (!r.ok) expect(r.error.code).toBe('gitea_webhook_install_failed');

      // 失败时不应在 DB 留下半成品行
      const row = await prisma.internalApp.findUnique({
        where: { giteaRepoFullName: fullName(happySlug) },
      });
      expect(row).toBeNull();
    });
  });
});
