import { Injectable, Logger } from '@nestjs/common';
import { SkipAssertAccess } from '@common/decorators/skip-assert-access.decorator';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { PrismaService } from '@core/database/prisma/prisma.service';
import { ContainerHostService } from './container-host.service';
import { InternalAppEventsService, EventType } from './events.service';

const GITEA_ORG_PREFIX = 'FFAIApps/';
const TARGET_REF = 'refs/heads/main';

export interface GiteaPushPayload {
  ref?: string;
  after?: string;
  repository?: {
    full_name?: string;
    clone_url?: string;
  };
  pusher?: { username?: string; email?: string };
}

export type WebhookOutcome =
  | { ok: true; action: 'deploy_queued'; deploymentId?: string; appId: string }
  | { ok: true; action: 'ignored'; reason: string }
  | { ok: false; httpStatus: number; code: string; message: string };

/**
 * Gitea webhook 处理服务（07-api §4.4）
 *
 * 安全分层（任一失败即拒）：
 * 1. HMAC-SHA256 校验 X-Gitea-Signature（防伪造）
 * 2. 仓库 full_name 必须以 `FFAIApps/` 开头（防 secret 泄漏后跨 org 滥用）
 * 3. ref 必须是 refs/heads/main（其它分支静默 ignore，不报错避免噪音）
 * 4. 在 DB 找到对应 app + status 不在 DESTROYED/DISABLED_ARCHIVED/PURGED
 *
 * Phase 0 实现：触发 deploy 后立即返回，部署异步执行（fire-and-forget，
 * 失败靠后续 logs / status 工具自检）。V1 会接 deployments 表 + audit 事件。
 */
@Injectable()
export class GiteaWebhookService {
  private readonly logger = new Logger(GiteaWebhookService.name);
  private readonly secret = process.env.INTERNAL_APP_GITEA_WEBHOOK_SECRET ?? '';

  constructor(
    private readonly prisma: PrismaService,
    private readonly containerHost: ContainerHostService,
    private readonly eventsSvc: InternalAppEventsService,
  ) {}

  /** 校验 HMAC-SHA256；body 必须是发送时**原始 JSON 字符串**，不是解析后再 stringify */
  verifySignature(rawBody: string, signatureHeader: string | undefined): boolean {
    if (!this.secret) {
      this.logger.warn('INTERNAL_APP_GITEA_WEBHOOK_SECRET 未配，所有 webhook 都将被拒');
      return false;
    }
    if (!signatureHeader) return false;
    const expected = createHmac('sha256', this.secret).update(rawBody).digest('hex');
    const a = Buffer.from(expected, 'hex');
    let b: Buffer;
    try {
      b = Buffer.from(signatureHeader, 'hex');
    } catch {
      return false;
    }
    if (a.length !== b.length) return false;
    return timingSafeEqual(a, b);
  }

  @SkipAssertAccess('Gitea push webhook 无 user 上下文（SYSTEM actor），鉴权走 HMAC + repository.full_name 必须以 FFAIApps/ 开头 + DB 反查 app 必须匹配 employeeSlug，不存在 IDOR')
  async handlePush(payload: GiteaPushPayload): Promise<WebhookOutcome> {
    // 1. org 前缀
    const fullName = payload.repository?.full_name ?? '';
    if (!fullName.startsWith(GITEA_ORG_PREFIX)) {
      this.logger.warn(`webhook 拒绝：仓库不在 FFAIApps org → ${fullName}`);
      return {
        ok: false,
        httpStatus: 403,
        code: 'foreign_org',
        message: `仓库 ${fullName} 不属于 FFAIApps`,
      };
    }

    // 2. ref 过滤（非 main 静默忽略）
    if (payload.ref !== TARGET_REF) {
      return { ok: true, action: 'ignored', reason: `ref=${payload.ref} 非 main，跳过` };
    }

    // 3. 找 app
    const repoName = fullName.slice(GITEA_ORG_PREFIX.length);
    const app = await this.findAppByRepoName(repoName);
    if (!app) {
      this.logger.warn(`webhook：仓库 ${fullName} 在 OA DB 找不到对应 app（可能 deploy_prepare 未走或被 destroy）`);
      return {
        ok: false,
        httpStatus: 404,
        code: 'app_not_found',
        message: `仓库 ${fullName} 没有匹配的 internal_app 行`,
      };
    }

    // 4. 状态拦截。列表必须跟下面三处 updateMany 的 notIn 列表一致，否则
    //    "上层放行 → deploy 实跑 → DB 不更新" 的状态/现实脱节会出现。
    //    DISABLED 是 IT-Admin 显式停用（06-data-model L142：DISABLED → HEALTHY/DESTROYED
    //    需 IT-Admin 显式动作触发），webhook push 不应隐式恢复。
    if (
      app.status === 'DESTROYED' ||
      app.status === 'DISABLED_ARCHIVED' ||
      app.status === 'PURGED' ||
      app.status === 'DISABLED'
    ) {
      return {
        ok: true,
        action: 'ignored',
        reason: `app status=${app.status}，部署跳过`,
      };
    }

    // 5. 触发部署（fire-and-forget）
    const cloneUrl = payload.repository?.clone_url ?? '';
    if (!cloneUrl) {
      return {
        ok: false,
        httpStatus: 400,
        code: 'missing_clone_url',
        message: 'payload 缺 repository.clone_url',
      };
    }

    const startedAt = Date.now();
    const commitSha = payload.after ?? null;
    const pusherEmail = payload.pusher?.email ?? null;

    // emit deploy_started 立即（actorRole=SYSTEM —— webhook 是 push 触发的异步链路；
    // pusher 信息留 payload 里，便于后续按"谁触发的 deploy"过滤）
    // 已知缺口：若 backend 在 started→succeeded/failed 之间崩溃，事件流会留下 orphan
    // started（无终态）。P2.5 通过 deployments 表 status 做兜底 reconciliation cron 修复。
    await this.eventsSvc.emit({
      eventType: EventType.APP_DEPLOY_STARTED,
      actorRole: 'SYSTEM',
      organizationId: app.organizationId,
      appId: app.id,
      employeeSlug: app.employeeSlug,
      payload: {
        commitSha,
        pusherUsername: payload.pusher?.username ?? null,
        pusherEmail,
        repoFullName: fullName,
      },
    });

    // 推进 BUILDING：与文档（06-data-model §状态机 / 07-api §3.1）声明的
    // PENDING → BUILDING → HEALTHY/FAILED 对齐。条件写防 destroy/disable 已先推进到
    // 终态（DESTROYED/DISABLED_*/PURGED/DISABLED）时被本路径覆盖回 BUILDING。
    const buildingResult = await this.prisma.internalApp.updateMany({
      where: {
        id: app.id,
        status: { notIn: ['DESTROYED', 'DISABLED_ARCHIVED', 'PURGED', 'DISABLED'] },
      },
      data: { status: 'BUILDING' },
    });
    if (buildingResult.count === 0) {
      // 上层 status 拦截理论上已过滤这些状态，到这里 count=0 = 并发竞态（前一瞬间被
      // destroy/disable）。记录后继续走 deploy（runDeployScript 会按 result 推进 FAILED）。
      this.logger.warn(
        `app ${app.id} BUILDING update skipped (race: status changed between guard and update)`,
      );
    }

    // 异步执行；不阻塞 webhook 响应（07-api §4.4 "立即返回"）
    void this.containerHost
      .runDeployScript({
        employeeSlug: app.employeeSlug,
        appSlug: app.appSlug,
        repoUrl: cloneUrl,
        runtime: app.runtime as 'node' | 'static',
        branch: 'main',
        appId: app.id,
        gitToken: process.env.INTERNAL_APP_GITEA_API_TOKEN ?? undefined,
      })
      .then(async (result) => {
        const durationMs = Date.now() - startedAt;
        if (result.ok) {
          this.logger.log(
            `deploy ok appId=${app.id} container=${result.containerName} url=${result.externalUrl}`,
          );
          // 推进 HEALTHY + 刷新 lastDeployedAt。
          // 历史 bug：原代码只 emit 事件不 update DB，所有 app 永远卡在"准备中"，
          // 即使容器健康；前端按 status 渲染所以全员"准备中"假象。
          // 详见 .learnings/2026-05-19-webhook-deploy-event-without-db-status-update.md
          //
          // 条件写防竞态：build 期间若员工/admin 调 destroy/disable 把 status 写成
          // DESTROYED/DISABLED_*/PURGED/DISABLED，本路径必须 no-op，不能覆盖回 HEALTHY。
          const okResult = await this.prisma.internalApp.updateMany({
            where: {
              id: app.id,
              status: { notIn: ['DESTROYED', 'DISABLED_ARCHIVED', 'PURGED', 'DISABLED'] },
            },
            data: {
              status: 'HEALTHY',
              lastDeployedAt: new Date(),
            },
          });
          if (okResult.count === 0) {
            this.logger.warn(
              `app ${app.id} HEALTHY update skipped (race: app moved to terminal state during deploy; container running but DB shows terminal—容器需要手工清理)`,
            );
          }
          return this.eventsSvc.emit({
            eventType: EventType.APP_DEPLOY_SUCCEEDED,
            actorRole: 'SYSTEM',
            organizationId: app.organizationId,
            appId: app.id,
            employeeSlug: app.employeeSlug,
            durationMs,
            payload: {
              commitSha,
              containerName: result.containerName,
              externalUrl: result.externalUrl,
            },
          });
        }
        this.logger.error(
          `deploy failed appId=${app.id} code=${result.error.code} msg=${result.error.message}`,
        );
        // 推进状态到 FAILED，前端能区分"准备中"vs"构建失败"。
        // 不写 lastDeployedAt（lastDeployedAt 语义 = 最后一次成功部署）。
        // 同样条件写防终态被覆盖。
        const failedResult = await this.prisma.internalApp.updateMany({
          where: {
            id: app.id,
            status: { notIn: ['DESTROYED', 'DISABLED_ARCHIVED', 'PURGED', 'DISABLED'] },
          },
          data: { status: 'FAILED' },
        });
        if (failedResult.count === 0) {
          this.logger.warn(
            `app ${app.id} FAILED update skipped (race: app moved to terminal state before deploy result landed)`,
          );
        }
        return this.eventsSvc.emit({
          eventType: EventType.APP_DEPLOY_FAILED,
          actorRole: 'SYSTEM',
          organizationId: app.organizationId,
          appId: app.id,
          employeeSlug: app.employeeSlug,
          outcome: 'FAIL',
          errorCode: result.error.code,
          durationMs,
          payload: {
            commitSha,
            message: result.error.message?.slice(0, 500) ?? null,
          },
        });
      })
      .catch(async (err: Error) => {
        const durationMs = Date.now() - startedAt;
        this.logger.error(`deploy threw appId=${app.id}: ${err.message}`);
        // 异常路径也要推进状态（不然 BUILDING 卡死），同样条件写防终态被覆盖。
        await this.prisma.internalApp
          .updateMany({
            where: {
              id: app.id,
              status: { notIn: ['DESTROYED', 'DISABLED_ARCHIVED', 'PURGED', 'DISABLED'] },
            },
            data: { status: 'FAILED' },
          })
          .then((r) => {
            if (r.count === 0) {
              this.logger.warn(
                `app ${app.id} FAILED-on-throw update skipped (race: app moved to terminal state)`,
              );
            }
          })
          .catch((e) =>
            this.logger.error(
              `failed to mark app ${app.id} FAILED after deploy throw: ${e.message}`,
            ),
          );
        return this.eventsSvc.emit({
          eventType: EventType.APP_DEPLOY_FAILED,
          actorRole: 'SYSTEM',
          organizationId: app.organizationId,
          appId: app.id,
          employeeSlug: app.employeeSlug,
          outcome: 'FAIL',
          errorCode: 'deploy_threw',
          durationMs,
          payload: { commitSha, message: err.message.slice(0, 500) },
        });
      });

    return { ok: true, action: 'deploy_queued', appId: app.id };
  }

  /**
   * 仓库名 → (employeeSlug, appSlug) 反向解析
   *
   * 仓库名格式 `{employeeSlug}-{appSlug}`，两边都允许含连字符，
   * 所以单纯字符串切分有歧义。做法：在每个可能的切点试一次 DB unique 查询，
   * 命中即返回（unique 约束保证最多 1 命中）。
   *
   * 复杂度：O(n) DB lookups（n = 仓库名连字符数 + 1），实测 n ≤ 6，
   * 每次走 uq_app_per_employee 索引，毫秒级。
   */
  private async findAppByRepoName(repoName: string) {
    const parts = repoName.split('-');
    if (parts.length < 2) return null;
    for (let i = 1; i < parts.length; i++) {
      const employeeSlug = parts.slice(0, i).join('-');
      const appSlug = parts.slice(i).join('-');
      const app = await this.prisma.internalApp.findUnique({
        where: { uq_app_per_employee: { employeeSlug, appSlug } },
      });
      if (app) return app;
    }
    return null;
  }
}
