import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import * as yaml from 'js-yaml';
import * as yauzl from 'yauzl';
import { ApiCode } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service';
import { QueueProducer } from '../../queues/queue-producer.service';

/**
 * ImportUploadService — 网页上传压缩包 → 触发 cold-import 的编排层。
 *
 * 安全核心:manifest.yaml + assemblers/ **永远取自 PAC 代码库** `data/<host>/`,不接受上传。
 *   importDirectory 从 manifest 的 host_name 决定写哪个 host,若让上传者带 manifest 即可篡改
 *   写入目标。故:
 *     - 按 scope.hostId 反查 host.name → 只认代码库 data/<name>/ 那份 manifest+assemblers
 *     - 从 zip 里**仅白名单提取** manifest.tables[].file 声明的文件名(basename 匹配,
 *       落 join(tmpDir, basename) → 天然挡 zip-slip 路径穿越)
 *     - manifest.host_name 天然 == 反查的 name → 不可能写到别的 host
 *
 * 长任务不在 HTTP 里同步跑:落盘就绪后 enqueue cold-import 队列,返回 jobId 供前端轮询。
 */
@Injectable()
export class ImportUploadService {
  private readonly logger = new Logger(ImportUploadService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly queue: QueueProducer,
  ) {}

  /** 代码库 host 数据根(与 ColdImportService.resolveHostDir 同约定)。 */
  private dataRoot(): string {
    return process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(process.cwd(), 'data');
  }

  /** 临时解压工作根;跑完由 processor 清理。 */
  private workRoot(): string {
    return process.env.PAC_IMPORT_WORK_DIR ?? path.join(this.dataRoot(), '.imports');
  }

  /**
   * 校验 + 落盘 + 入队。返回前端轮询用的 jobId。
   * @param scope     调用者(宿主)scope,来自 token
   * @param zipBuffer 上传的 zip 内容(multer memoryStorage buffer)
   * @param userId    触发人(进 triggeredBy)
   */
  async prepareAndEnqueue(
    scope: { hostId: string; tenantId: string },
    zipBuffer: Buffer,
    userId: string,
    dryRun = false,
  ): Promise<{ jobId: string }> {
    // 上传内容先落盘成临时 zip(yauzl 从文件流式解压);finally 删除
    await fs.promises.mkdir(this.workRoot(), { recursive: true });
    const zipPath = path.join(this.workRoot(), `upload-${randomUUID()}.zip`);
    await fs.promises.writeFile(zipPath, zipBuffer);
    try {
      const host = await this.prisma.host.findUnique({ where: { id: scope.hostId } });
      if (!host) {
        throw new BizError(ApiCode.CLIENT_BAD_REQUEST, `host 不存在: ${scope.hostId}`);
      }

      // 1. 代码库 data/<name>/ 必须已配置 manifest(否则该 host 不支持文件导入)
      const srcDir = path.join(this.dataRoot(), host.name);
      const manifestPath = path.join(srcDir, 'manifest.yaml');
      if (!fs.existsSync(manifestPath)) {
        throw new BizError(
          ApiCode.CLIENT_BAD_REQUEST,
          `该宿主未配置文件导入(缺 data/${host.name}/manifest.yaml)`,
        );
      }

      // 2. 白名单 = manifest.tables[].file(都是根级 basename,如 customer_basic_info.csv)
      const manifest = yaml.load(fs.readFileSync(manifestPath, 'utf-8')) as {
        tables?: Array<{ file?: string; group?: string }>;
      };
      const whitelist = new Set(
        (manifest.tables ?? [])
          .map((t) => t.file)
          .filter((f): f is string => !!f)
          .map((f) => path.basename(f)),
      );
      if (whitelist.size === 0) {
        throw new BizError(
          ApiCode.CLIENT_BAD_REQUEST,
          `manifest.tables 为空,无可导入文件声明`,
        );
      }

      // 3. 临时目录做成"迷你 data 根"(与 data/ 同构):
      //    tmpRoot/<host>/  ← manifest + assemblers + 解压的数据文件
      //    tmpRoot/_shared/ ← 共享字典层副本
      //    assembler 的 dict_includes 用 ../../_shared/dict/ 相对引用(相对 assemblers/ 上两级),
      //    必须保住这个相对位置,否则共享字典找不到(CLI dry-run 实测抓到)。
      const tmpRoot = path.join(this.workRoot(), `import-${randomUUID()}`);
      const runDir = path.join(tmpRoot, host.name);
      await fs.promises.mkdir(runDir, { recursive: true });
      await fs.promises.copyFile(manifestPath, path.join(runDir, 'manifest.yaml'));
      const assemblersSrc = path.join(srcDir, 'assemblers');
      if (fs.existsSync(assemblersSrc)) {
        await fs.promises.cp(assemblersSrc, path.join(runDir, 'assemblers'), { recursive: true });
      }
      const sharedSrc = path.join(this.dataRoot(), '_shared');
      if (fs.existsSync(sharedSrc)) {
        await fs.promises.cp(sharedSrc, path.join(tmpRoot, '_shared'), { recursive: true });
      }

      // 4. 流式解压 —— 仅提取白名单 basename(挡 zip-slip)。
      //    yauzl 对损坏包 / 路径穿越条目会 fail-closed(整包拒绝)→ 转友好 400,不冒泡成 500。
      let extracted: Set<string>;
      try {
        extracted = await extractWhitelisted(zipPath, runDir, whitelist);
      } catch (err) {
        await fs.promises.rm(tmpRoot, { recursive: true, force: true });
        throw new BizError(
          ApiCode.CLIENT_BAD_REQUEST,
          `压缩包解析失败(可能损坏或含非法路径条目): ${err instanceof Error ? err.message : err}`,
        );
      }

      // 5. 宽容 + 成组完整性校验(不再要求全表齐 —— PAC 各主体独立,装载层已宽容跳过):
      //    ① 非空:压缩包至少含一个可识别数据文件
      //    ② 成组:manifest 声明 group 的表,组内要么全在、要么整组不在;部分在 → 报错
      //       (半组产不出主体,如 treatment_plan 头+行缺一 → planned 产 0 行)
      //    ③ 独立表(无 group)缺失:放行,只少那部分 facts
      if (extracted.size === 0) {
        await fs.promises.rm(tmpRoot, { recursive: true, force: true });
        throw new BizError(
          ApiCode.CLIENT_BAD_REQUEST,
          `压缩包不含任何可识别的数据文件(需匹配 manifest.tables 声明的文件名)`,
        );
      }
      const incompleteGroups = findIncompleteGroups(manifest.tables ?? [], extracted);
      if (incompleteGroups.length > 0) {
        await fs.promises.rm(tmpRoot, { recursive: true, force: true });
        throw new BizError(
          ApiCode.CLIENT_VALIDATION_FAILED,
          `成组主体不完整:${incompleteGroups
            .map((g) => `"${g.group}" 缺 ${g.missing.join(',')}`)
            .join(';')}(该组表须一起上传,或整组都不传)`,
          { incompleteGroups },
        );
      }

      this.logger.log(
        `import 就绪 host=${host.name} runDir=${runDir} 文件=${extracted.size}/${whitelist.size}` +
          (extracted.size < whitelist.size
            ? `(缺 ${whitelist.size - extracted.size} 张独立表,宽容跳过)`
            : ''),
      );

      // 6. 入队,返回 jobId。dir=runDir(跑),cleanupDir=tmpRoot(processor 跑完清整个迷你根)
      const { jobId } = await this.queue.enqueueColdImport({
        hostId: host.id,
        tenantId: scope.tenantId,
        hostName: host.name,
        dir: runDir,
        cleanupDir: tmpRoot,
        dryRun,
        triggeredBy: `upload:${userId}`,
      });
      return { jobId };
    } finally {
      // 上传的原始 zip 用完即删(已解压到 runDir);迷你根由 processor 跑完清理
      await fs.promises.rm(zipPath, { force: true }).catch(() => undefined);
    }
  }

  /**
   * 查一次进度(前端轮询)。job active 时补查运行中的 sync_log.metadata.cohortDone/cohortTotal
   * → 前端算真实百分比(importDirectory 每批实时写)。dryRun 不建 sync_log,只有 state。
   */
  async getProgress(jobId: string): Promise<{
    state: string;
    result: unknown;
    failedReason: string | null;
    progress: { done: number; total: number } | null;
  }> {
    const job = await this.queue.getColdImportJob(jobId);
    if (!job) return { state: 'not_found', result: null, failedReason: null, progress: null };
    let progress: { done: number; total: number } | null = null;
    if (job.state === 'active' && job.hostId) {
      const log = await this.prisma.syncLog.findFirst({
        where: { hostId: job.hostId, status: 'running' },
        orderBy: { startedAt: 'desc' },
        select: { metadata: true },
      });
      const m = log?.metadata as { cohortDone?: number; cohortTotal?: number } | null;
      if (m?.cohortTotal) progress = { done: m.cohortDone ?? 0, total: m.cohortTotal };
    }
    return {
      state: job.state,
      result: job.returnValue,
      failedReason: job.failedReason,
      progress,
    };
  }
}

/**
 * yauzl 流式解压,只提取 basename 命中白名单的条目。
 * 只用 basename + join(destDir, ...) → 无法逃逸目标目录(zip-slip 防护)。
 * 返回实际提取到的 basename 集合(供缺文件校验)。
 * export 供单测(tests/cold-import-extract.spec.ts)。
 */
export function extractWhitelisted(
  zipPath: string,
  destDir: string,
  whitelist: Set<string>,
): Promise<Set<string>> {
  return new Promise((resolve, reject) => {
    const extracted = new Set<string>();
    yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
      if (err || !zipfile) return reject(err ?? new Error('无法打开压缩包'));
      zipfile.on('error', reject);
      zipfile.on('end', () => resolve(extracted));
      zipfile.on('entry', (entry: YauzlEntry) => {
        const base = path.basename(entry.fileName);
        // 目录项 / 白名单外 → 跳过。
        // (路径穿越 `../` 由 yauzl 自身 fail-closed 直接 emit error;此处 basename+join 是纵深防御)
        if (entry.fileName.endsWith('/') || base.includes('..') || !whitelist.has(base)) {
          return zipfile.readEntry();
        }
        zipfile.openReadStream(entry, (e, readStream) => {
          if (e || !readStream) return reject(e ?? new Error('读取压缩条目失败'));
          const ws = fs.createWriteStream(path.join(destDir, base));
          readStream.on('error', reject);
          ws.on('error', reject);
          ws.on('close', () => {
            extracted.add(base);
            zipfile.readEntry();
          });
          readStream.pipe(ws);
        });
      });
      zipfile.readEntry();
    });
  });
}

/**
 * 成组完整性校验(通用,读宿主 manifest 的 group 声明):
 * 返回"部分在但不全"的组(半组产不出主体 → 上传应拒)。组内全在或整组都不在 → 不返回(放行)。
 * export 供单测(tests/cold-import-groups.spec.ts)。
 */
export function findIncompleteGroups(
  tables: Array<{ file?: string; group?: string }>,
  extracted: Set<string>,
): Array<{ group: string; present: string[]; missing: string[] }> {
  const groups = new Map<string, string[]>(); // group → [basename...]
  for (const t of tables) {
    if (t.group && t.file) {
      const arr = groups.get(t.group) ?? [];
      arr.push(path.basename(t.file));
      groups.set(t.group, arr);
    }
  }
  return [...groups.entries()]
    .map(([group, members]) => ({
      group,
      present: members.filter((f) => extracted.has(f)),
      missing: members.filter((f) => !extracted.has(f)),
    }))
    .filter((g) => g.present.length > 0 && g.missing.length > 0);
}

// ── yauzl 最小类型(遵循仓库"不引 @types"的先例,如 assistant.controller UploadedAudio)──
interface YauzlEntry {
  fileName: string;
}
