import { Injectable, Logger } from '@nestjs/common';
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

interface ExecOptions {
  maxBuffer?: number;
  timeout?: number;
}
interface ExecResult {
  stdout: string;
  stderr: string;
}

/**
 * 容器宿主访问层
 *
 * Phase 0：MCP 后端可能与容器运行宿主**异机**（如 backend 在 dev 机器，
 * 容器 / Caddy 在 43.166.182.155）。本服务统一封装"如何到达宿主执行 docker"：
 *
 *   - `INTERNAL_APP_DEPLOY_SSH_HOST` 已配 → 走 `ssh user@host -- <docker cmd>`
 *   - 未配 → 假定本机就是宿主，直接 `docker ...`
 *
 * 目前只暴露 `getContainerLogs`；deploy / restart / destroy 子命令在 Phase 0 中后期补。
 *
 * 详见 07-api §3.3 logs 工具 + 03-architecture §6.2 部署链路
 */
/**
 * deploy-container.sh 结构化输出（参见 scripts/internal-app-platform/deploy-container.sh）
 */
export interface DeployResult {
  ok: true;
  containerName: string;
  internalHost: string;
  externalUrl: string;
  logsTail: string[];
}

export interface DeployError {
  ok: false;
  error: { code: string; message: string; details?: Record<string, unknown> };
}

export interface DeployScriptArgs {
  employeeSlug: string;
  appSlug: string;
  repoUrl: string;
  runtime: 'node' | 'static';
  branch?: string;
  appId?: string;
  gitToken?: string;
}

@Injectable()
export class ContainerHostService {
  private readonly logger = new Logger(ContainerHostService.name);

  private readonly sshHost = process.env.INTERNAL_APP_DEPLOY_SSH_HOST ?? '';
  private readonly sshUser = process.env.INTERNAL_APP_DEPLOY_SSH_USER ?? 'lijian';
  private readonly sshKey = process.env.INTERNAL_APP_DEPLOY_SSH_KEY ?? '';
  // 宿主上 deploy-container.sh 的入口。`/usr/local/bin/ffoa-deploy` 是 **symlink**
  // 指向 repo 内的 scripts/internal-app-platform/deploy-container.sh，由 setup-test-server.sh
  // 装 + deploy-test.yml 每次部署兜底 `ln -sfn` 保持自愈。**禁止改成 plain cp**——历史
  // 上是 cp，导致 git 更新脚本不生效、员工 push 全挂。详见
  // .learnings/2026-05-19-ffoa-deploy-binary-stale-symlink-fix.md
  //
  // ⚠️ env `INTERNAL_APP_DEPLOY_BINARY` 可覆盖默认路径——若运维设了 env 指向自定义路径，
  // 运维同样有责任保证该路径是 symlink（或在部署流程里同步更新），否则同问题会重现。
  private readonly deployBinary =
    process.env.INTERNAL_APP_DEPLOY_BINARY ?? '/usr/local/bin/ffoa-deploy';
  // 员工 app 落点的通配公网根域。**必须**与 mcp-tools.service.ts:30 PUBLIC_DOMAIN
  // 同源（同一 ConfigService 读取），否则 deploy_prepare 返的 url 跟 deploy-container.sh
  // 写的 Caddy site 域名不一致，前端访问命中 nginx default vhost 返空 200。
  //
  // 历史 bug：deploy-container.sh 直接读 $INTERNAL_APP_PUBLIC_DOMAIN env，但 NestJS
  // ConfigService 读 .env **不写 process.env**，子进程（execAsync 起的 deploy 脚本）
  // 拿不到 → fallback 到默认生产域名。test 上 itadmin-ffoa 命中过这条路径。
  // 详见 .learnings/2026-05-19-nestjs-configservice-not-writing-process-env.md
  private readonly appsDomain =
    process.env.INTERNAL_APP_PUBLIC_DOMAIN ?? 'apps.ffworkspace.faradayfuture.com';

  /**
   * 取容器最近 N 行 stdout+stderr 合并日志（docker logs --tail N --timestamps）
   *
   * @param containerName  e.g. `ffoa-app-lijian-hello`
   * @param tailLines      默认 100，硬上限 1000（防止巨量日志撑爆 MCP 响应）
   * @returns { stdout: string, truncatedByLineCap: boolean }
   * @throws Error('container_not_found') / Error('host_unreachable')
   */
  async getContainerLogs(
    containerName: string,
    tailLines = 100,
  ): Promise<{ stdout: string; truncatedByLineCap: boolean }> {
    const cap = Math.min(Math.max(1, tailLines), 1000);
    const truncatedByLineCap = tailLines > cap;
    // containerName 已经被上层用正则约束（ffoa-app-<slug>-<slug>），无 shell 注入
    // 仍走带 -- 显式分隔参数，docker logs 选项放前
    const dockerArgs = `logs --tail ${cap} --timestamps ${containerName}`;
    try {
      const { stdout, stderr } = await this.runHost(`docker ${dockerArgs}`, {
        maxBuffer: 8 * 1024 * 1024,
        timeout: 10_000,
      });
      // docker logs 把容器内的 stderr 通过 child stderr 输出；合并展示
      const combined = stderr ? `${stdout}${stdout && !stdout.endsWith('\n') ? '\n' : ''}${stderr}` : stdout;
      return { stdout: combined, truncatedByLineCap };
    } catch (err: unknown) {
      const e = err as { stderr?: string; message?: string; code?: number };
      const msg = (e.stderr ?? e.message ?? '').toLowerCase();
      if (msg.includes('no such container')) {
        throw new Error('container_not_found');
      }
      if (msg.includes('connection refused') || msg.includes('connection closed') || msg.includes('host key verification failed')) {
        throw new Error('host_unreachable');
      }
      this.logger.error(`docker logs failed: ${e.stderr ?? e.message}`);
      throw new Error('docker_logs_failed');
    }
  }

  /** 容器命名规则集中此处，避免上层散落字符串拼接 */
  buildContainerName(employeeSlug: string, appSlug: string): string {
    return `ffoa-app-${employeeSlug}-${appSlug}`;
  }

  /**
   * 销毁容器 + 移除 Caddy 路由（destroy 工具调用）
   *
   * Phase 0 行为：
   * - docker rm -f：停 + 删容器（数据保留在 /srv/internal-apps/repos/ + named volume）
   * - 删 /srv/caddy/sites/{slug}.caddy + caddy reload：从此对外不可访问
   * - **repo 目录 / DB 行不动**：30 天恢复期内 IT-Admin 可重建容器
   * - Gitea 归档放到 Phase 1（需 INTERNAL_APP_GITEA_API_TOKEN 已配）
   *
   * 幂等：容器/路由文件已不存在不报错（员工二次 destroy 视为成功）。
   */
  async destroyContainer(
    employeeSlug: string,
    appSlug: string,
  ): Promise<{ ok: true } | { ok: false; code: string; message: string }> {
    const containerName = this.buildContainerName(employeeSlug, appSlug);
    const litestreamContainer = `${containerName}-bk`;
    const siteFile = `/srv/caddy/sites/${employeeSlug}-${appSlug}.caddy`;
    const caddyContainer = process.env.INTERNAL_APP_CADDY_CONTAINER ?? 'ffoa-caddy';

    // 1a. 先停 litestream sidecar（让它优先 flush 最后一次 wal 到 MinIO；幂等）
    try {
      await this.runHost(`docker rm -f ${litestreamContainer}`, {
        timeout: 15_000,
      });
    } catch {
      // sidecar 不存在或已停—不影响主流程，30 天保留期内 MinIO 上备份仍可恢复
    }

    // 1b. 删 main 容器
    try {
      await this.runHost(`docker rm -f ${containerName}`, {
        timeout: 15_000,
      });
    } catch (err) {
      const msg = ((err as Error).message ?? '').toLowerCase();
      if (!msg.includes('no such container')) {
        this.logger.error(`docker rm failed: ${(err as Error).message}`);
        return { ok: false, code: 'docker_rm_failed', message: 'docker rm 失败' };
      }
    }

    // 2. 删 Caddy site 文件 + reload（用 rm -f 幂等）
    try {
      await this.runHost(`rm -f ${siteFile}`, { timeout: 5_000 });
    } catch (err) {
      this.logger.error(`rm caddy site failed: ${(err as Error).message}`);
      return { ok: false, code: 'caddy_site_rm_failed', message: '删 Caddy 配置失败' };
    }
    try {
      await this.runHost(
        `docker exec ${caddyContainer} caddy reload --config /etc/caddy/Caddyfile`,
        { timeout: 10_000 },
      );
    } catch (err) {
      this.logger.error(`caddy reload failed: ${(err as Error).message}`);
      return { ok: false, code: 'caddy_reload_failed', message: 'Caddy reload 失败' };
    }

    this.logger.log(`destroyed container=${containerName}, removed site=${siteFile}`);
    return { ok: true };
  }

  /**
   * 执行 deploy-container.sh，返回脚本最后一行 JSON 解析后的结构化结果。
   *
   * 脚本超时上限 180s（npm ci + 镜像 pull 极端情况）；超时按 host_unreachable 报。
   * 注入参数全部走 --flag value 形式；slug 已在调用上层正则约束（^[a-z0-9][a-z0-9-]{1,20}[a-z0-9]$）
   * 因此可直接拼 shell 不会注入。
   */
  async runDeployScript(args: DeployScriptArgs): Promise<DeployResult | DeployError> {
    const flags = [
      `--employee-slug ${this.shellEscape(args.employeeSlug)}`,
      `--app-slug ${this.shellEscape(args.appSlug)}`,
      `--repo-url ${this.shellEscape(args.repoUrl)}`,
      `--runtime ${args.runtime}`,
      `--branch ${this.shellEscape(args.branch ?? 'main')}`,
      // 显式传 apps-domain，不依赖子进程从 env 拿（NestJS ConfigService 不写 process.env）
      `--apps-domain ${this.shellEscape(this.appsDomain)}`,
    ];
    if (args.appId) flags.push(`--app-id ${this.shellEscape(args.appId)}`);
    if (args.gitToken) flags.push(`--git-token ${this.shellEscape(args.gitToken)}`);

    const remoteCmd = `${this.deployBinary} ${flags.join(' ')}`;
    this.logger.log(
      `deploy script invoked employeeSlug=${args.employeeSlug} appSlug=${args.appSlug} runtime=${args.runtime}`,
    );

    try {
      const { stdout } = await this.runHost(remoteCmd, {
        maxBuffer: 4 * 1024 * 1024,
        timeout: 180_000,
      });
      return this.parseDeployOutput(stdout);
    } catch (err: unknown) {
      const e = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean; signal?: string };
      // 脚本 emit_err 走 exit 1 + JSON 到 stdout —— runHost 视为失败但 stdout 仍可用
      if (e.stdout) {
        const parsed = this.parseDeployOutput(e.stdout);
        if (!parsed.ok) return parsed;
      }
      if (e.killed && e.signal === 'SIGTERM') {
        return {
          ok: false,
          error: {
            code: 'deploy_timeout',
            message: '部署脚本超过 180 秒未完成（可能镜像拉取卡住）',
          },
        };
      }
      const msg = (e.stderr ?? e.message ?? '').toLowerCase();
      if (msg.includes('connection') || msg.includes('host key')) {
        return {
          ok: false,
          error: { code: 'host_unreachable', message: '部署宿主不可达' },
        };
      }
      this.logger.error(`deploy script failed: ${e.stderr ?? e.message}`);
      return {
        ok: false,
        error: { code: 'deploy_script_failed', message: '部署脚本异常退出' },
      };
    }
  }

  private parseDeployOutput(stdout: string): DeployResult | DeployError {
    // deploy-container.sh 保证最后一行是 compact JSON（jq -sc）；
    // 前面可能有 docker pull 进度等噪音。所以从末尾逐行找"以 { 开头"的第一行即可。
    // 不要用 lastIndexOf('{')——结果 JSON 中 details 嵌套对象的内层 { 会误中选。
    const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
    const jsonLine = [...lines].reverse().find((l) => l.startsWith('{')) ?? '';
    try {
      const parsed = JSON.parse(jsonLine) as DeployResult | DeployError;
      if (typeof parsed.ok !== 'boolean') throw new Error('missing ok field');
      return parsed;
    } catch {
      this.logger.error(`deploy script output 无法解析为 JSON: ${jsonLine.slice(0, 200)}`);
      return {
        ok: false,
        error: {
          code: 'deploy_output_unparseable',
          message: '部署脚本输出非 JSON，请联系平台管理员',
          details: { tail: jsonLine.slice(0, 200) },
        },
      };
    }
  }

  /** 单引号包裹做 shell 转义；用于拼装**远端** sh -c 字符串。 */
  private shellEscape(s: string): string {
    return `'${s.replace(/'/g, `'\\''`)}'`;
  }

  /**
   * 在容器宿主上跑一条命令（远端走 SSH，本机直跑）。
   *
   * 关键点：远端走 `execFile('ssh', [...])` —— 本地 /bin/sh 不再参与解析，
   * 所以 remoteCmd 只需要满足**远端** sh -c 的转义（shellEscape 的单层 quote）。
   * 历史 bug：`exec()` 会先让本地 shell 剥一层 quote、再把 argv join 后丢给 ssh，
   * 等于双层解析单层转义 = 没转义；当前所有调用源都是受信内部数据，未触发 RCE，
   * 但接入员工输入即危险。详见 PR #396 AI Review §risk-3。
   */
  private runHost(remoteCmd: string, options: ExecOptions): Promise<ExecResult> {
    if (!this.sshHost) {
      return execAsync(remoteCmd, options) as Promise<ExecResult>;
    }
    const sshArgs = [
      '-o', 'BatchMode=yes',
      '-o', 'StrictHostKeyChecking=accept-new',
      '-o', 'ConnectTimeout=5',
    ];
    if (this.sshKey) sshArgs.push('-i', this.sshKey);
    sshArgs.push(`${this.sshUser}@${this.sshHost}`, '--', remoteCmd);
    return execFileAsync('ssh', sshArgs, options) as Promise<ExecResult>;
  }
}
