import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { JVS_DW_PRESETS } from './jvs-dw-preset';
import {
  ApiCode,
  ROLE_PERMISSIONS,
  type AccessTokenPayload,
  type ExchangeCodeResponse,
  type MockLoginRequest,
  type MockUser,
  type Permission,
  type RefreshTokenResponse,
  type TokenDictionary,
  type TokenExchangeResponse,
  type TokenExchangeRequest,
  type UserRole,
} from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { randomCode, verifySecret } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { AlertService } from '../../common/alerting/alert.service';
import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users';

const CODE_PREFIX = 'pac:exchange-code:';
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
/// TTL 内可重复换、返回同一票(宿主重载/双次加载都不误报「登录已过期」),到点自然失效。
const REFRESH_PREFIX = 'pac:refresh:';
/// 诊断反查:code 首次消费时记 codeTag→{身份, 时刻},供 client-diag 判"是否复用已消费 code"。
/// TTL 15 分钟 —— 覆盖"首开成功→稍后重载失败"的时间跨度,又不长期驻留。
const CODE_SEEN_PREFIX = 'pac:diag:code-seen:';
const CODE_SEEN_TTL_SECONDS = 15 * 60;
/// 诊断:mint 时记 codeTag→{userId, 签发时刻}(判失效 code 的身份与"有没有被消费过")。
const CODE_ISSUED_PREFIX = 'pac:diag:code-issued:';
/// 诊断:userId→最新签发的 codeTag(判失效时用的 code 是不是"最新那张",还是宿主拿了旧的)。
const LATEST_CODE_PREFIX = 'pac:diag:latest-code:';
const DIAG_CODE_TTL_SECONDS = 15 * 60;
// 注:不做告警去重 —— 每次会话失效都推企微。宿主若复用 code 刷爆群,正好是排查宿主的信号。

/**
 * Refresh token payload(W3 末 A 方案降级版)
 *
 * 容纳重签 access token 所需的全部 user 上下文(role/orgScope/dictionary),
 * **无需** refresh 时回调 host SSO 拉权限即可重发 access。
 *
 * 安全考量:
 *   - refresh token 用独立 secret(JWT_REFRESH_SECRET),access 烂不代表 refresh 烂
 *   - Redis jti 白名单(7 天 TTL),被偷的 refresh 不会无限刷(rotation 后老 jti 立即作废)
 *   - 权限变更/禁用最长 7 天(refresh TTL)才生效 — POC 阶段可接受;
 *     生产前要加 host SSO `/users/{id}` 回调拉最新 role/clinics(见下方 TODO)
 */
interface RefreshPayload {
  sub: string;
  hostId: string;
  tenantId: string;
  orgScope: string[];
  role: UserRole;
  dictionary?: TokenDictionary;
  jti: string;
}

/** 一个 code 的签发/消费足迹(判"最新那张 / 消费过没 / 签发到现在多久")。 */
interface CodeTrace {
  userId: string | null;
  hostId: string | null;
  tenantId: string | null;
  issuedAt: string | null;
  /** 签发到"现在"的秒数 —— 直接量出 mint→验证的时间差(> code TTL 即过期而死)。 */
  ageSeconds: number | null;
  usedBefore: boolean;
  consumedAt: string | null;
  isLatest: boolean | null;
}

/** 前端 bootstrap 诊断信标载荷(与 web use-auth-bootstrap.ts 的 reportDiag 对齐)。 */
interface ClientDiagPayload {
  ev?: string;
  hadCode?: boolean;
  codeTag?: string | null;
  storedAccess?: boolean;
  storedRefresh?: boolean;
  storedAccessValid?: boolean;
  err?: string | null;
  storageWritable?: boolean;
  isEmbedded?: boolean;
  hostOrigin?: string | null;
  isReload?: boolean;
  ua?: string;
}

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly redis: RedisService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
    private readonly alert: AlertService,
  ) {}

  /** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */
  private _accessExpiresInSeconds?: number;
  private get accessExpiresInSeconds(): number {
    return (this._accessExpiresInSeconds ??= parseDurationToSeconds(
      this.config.getOrThrow<string>('jwt.expiresIn'),
    ));
  }

  /** 当前 host 的 actionUrls(跳转模板)—— 会话视图带给前端;缺失返 {}。 */
  async getActionUrls(hostId: string): Promise<Record<string, string | null>> {
    const host = await this.prisma.host.findUnique({
      where: { id: hostId },
      select: { actionUrls: true },
    });
    return (host?.actionUrls as Record<string, string | null>) ?? {};
  }

  /// 服务端诊所名字典(从摄入数据派生,存 host.clinicNames)。session 合并进 dictionary.clinics。
  async getClinicNames(hostId: string): Promise<Record<string, string>> {
    const host = await this.prisma.host.findUnique({
      where: { id: hostId },
      select: { clinicNames: true },
    });
    return (host?.clinicNames as Record<string, string>) ?? {};
  }

  async exchangeToken(req: TokenExchangeRequest): Promise<TokenExchangeResponse> {
    const host = await this.prisma.host.findUnique({ where: { appId: req.appId } });
    if (!host || !host.active) {
      throw new BizError(ApiCode.AUTH_INVALID_CREDENTIALS);
    }
    if (!verifySecret(req.appSecret, host.appSecretHash)) {
      throw new BizError(ApiCode.AUTH_INVALID_CREDENTIALS);
    }

    // 契约 user 上下文 → token,与 mockLogin 共用同一条 issueTokens(展开 + 签发)。
    const { accessToken, refreshToken } = await this.issueTokens(host, req.user);

    const code = randomCode(24);
    const codeTtl = this.config.getOrThrow<number>('exchangeCodeTtlSeconds');
    await this.redis.setEx(
      CODE_PREFIX + code,
      JSON.stringify({ accessToken, refreshToken }),
      codeTtl,
    );
    // 诊断:记 codeTag→身份/签发时刻 + userId→最新 codeTag(供失败时判"是不是最新那张、消费过没")。
    void this.recordCodeIssued(code.slice(0, 8), req.user.userId, host.id, req.user.tenantId).catch(
      () => {
        /* 诊断落库失败静默 */
      },
    );

    const expiresIn = this.accessExpiresInSeconds;

    return {
      accessToken,
      refreshToken,
      code,
      expiresIn,
      codeExpiresIn: codeTtl,
    };
  }

  async exchangeCode(code: string): Promise<ExchangeCodeResponse> {
    const expiresIn = this.accessExpiresInSeconds;
    const tag = code.slice(0, 8); // 埋点用前缀(不泄全码),相邻日志可对同一 code 关联
    // 用 get(不删)—— code 在 TTL 内可重复换、返回同一票;到点由 TTL 自然失效。
    // 这样宿主重载 / 双次加载(TTL 内)都换得到,不再误报「登录已过期」。
    const raw = await this.redis.get(CODE_PREFIX + code);
    if (!raw) {
      // 不存在 = TTL 已过期,或未知 / 伪造 code。反查足迹(最新那张? 消费过? code 龄?)。
      const trace = await this.traceCode(tag);
      // 全码只落服务端日志(便于人工核对;已失效不可换),不进企微。
      this.logger.warn(
        `[exchange-code] MISS 失效(TTL 过期或未知 code) code=${tag}… fullCode=${code} ${this.fmtTrace(trace)}`,
      );
      throw new BizError(ApiCode.AUTH_INVALID_CODE);
    }
    // 首次换 vs TTL 内重复换:靠 code-seen 区分(仅日志/诊断用,两者都正常返同一票)。
    const firstTime = !(await this.redis.get(CODE_SEEN_PREFIX + tag));
    if (firstTime) {
      this.logger.log(`[exchange-code] consumed(首次) code=${tag}…`);
      // 诊断反查:记 codeTag→{身份, 首次换时刻},供 client-diag 判"复用/消费过"(不阻塞主流程)。
      void this.recordCodeSeen(tag, raw).catch(() => {
        /* 诊断落库失败静默 */
      });
    } else {
      // TTL 内被再次换 = 宿主重载/双次加载,现在正常返票(不再是 MISS)。
      this.logger.log(`[exchange-code] 重复换(TTL 内,幂等返同一票) code=${tag}…`);
    }
    const parsed = JSON.parse(raw) as { accessToken: string; refreshToken: string };
    return {
      accessToken: parsed.accessToken,
      refreshToken: parsed.refreshToken,
      expiresIn,
    };
  }

  /**
   * 前端 bootstrap 诊断信标 —— 落一行服务端日志;失败事件额外推企微监控群。
   * 关键证据:嵌入首开重载时 exchange code 失败后,store 里到底有没有 token
   * (storedAccess/storedRefresh)+ 存储能不能用(storageWritable)—— 把「登录已过期」
   * 最后一环(iframe localStorage 读不到)钉死。codeTag 与相邻 [exchange-code] 埋点关联。
   */
  clientDiag(d: ClientDiagPayload): void {
    const b = (v: boolean | undefined) => (v === undefined ? '?' : v ? 'Y' : 'N');
    this.logger.log(
      `[client-diag] ev=${d.ev ?? '?'} code=${d.codeTag ?? '-'}… hadCode=${b(d.hadCode)} ` +
        `storedAccess=${b(d.storedAccess)} storedRefresh=${b(d.storedRefresh)} accessValid=${b(d.storedAccessValid)} ` +
        `storageWritable=${b(d.storageWritable)} embedded=${b(d.isEmbedded)} reload=${b(d.isReload)}` +
        (d.hostOrigin ? ` host=${d.hostOrigin}` : '') +
        (d.err ? ` err=${d.err}` : ''),
    );
    // 只对"真·嵌入/换票失败"推企微:①带 code 的失败(换票没成),或 ②在宿主 iframe 内失败。
    // 直接浏览器打开 /plans(无 code、非嵌入)= 正常走 mock/SSO,不是故障,只落日志不推(否则误报刷屏)。
    // 运行期掉线(用着用着 access 过期 + refresh 被拒 → api-client clear)也纳入:
    //   runtime-refresh-fail(refresh 被拒)/ runtime-auth-clear(受保护请求鉴权失败清会话)。
    const isFailure =
      d.ev === 'no-credentials' ||
      d.ev === 'refresh-fail' ||
      d.ev === 'runtime-refresh-fail' ||
      d.ev === 'runtime-auth-clear';
    const worthAlert = isFailure && (d.hadCode === true || d.isEmbedded === true);
    if (worthAlert) {
      void this.alertSessionExpiry(d).catch((e) =>
        this.logger.warn(`[client-diag] 告警推送失败: ${e instanceof Error ? e.message : String(e)}`),
      );
    }
  }

  /** 首次消费时记 codeTag→{身份, 时刻}(供 client-diag 反查是否复用已消费 code)。脱敏:只存 opaque id。 */
  private async recordCodeSeen(tag: string, raw: string): Promise<void> {
    try {
      const { accessToken } = JSON.parse(raw) as { accessToken: string };
      const decoded = this.jwt.decode(accessToken) as {
        sub?: string;
        hostId?: string;
        tenantId?: string;
      } | null;
      await this.redis.setEx(
        CODE_SEEN_PREFIX + tag,
        JSON.stringify({
          at: new Date().toISOString(),
          sub: decoded?.sub ?? null,
          hostId: decoded?.hostId ?? null,
          tenantId: decoded?.tenantId ?? null,
        }),
        CODE_SEEN_TTL_SECONDS,
      );
    } catch {
      /* 诊断落库失败静默 */
    }
  }

  /** mint 时记 codeTag→{身份, 签发时刻} + userId→最新 codeTag(供失败时判"最新那张 / 消费过没")。 */
  private async recordCodeIssued(
    tag: string,
    userId: string,
    hostId: string,
    tenantId: string,
  ): Promise<void> {
    try {
      const at = new Date().toISOString();
      await this.redis.setEx(
        CODE_ISSUED_PREFIX + tag,
        JSON.stringify({ userId, hostId, tenantId, at }),
        DIAG_CODE_TTL_SECONDS,
      );
      // 同一 user 每次 mint 覆盖 → 永远指向最新签发的 code
      await this.redis.setEx(LATEST_CODE_PREFIX + userId, tag, DIAG_CODE_TTL_SECONDS);
    } catch {
      /* 诊断落库失败静默 */
    }
  }

  /**
   * 反查一个 codeTag 的签发/消费足迹:
   *   usedBefore = 是否已被成功消费(重载复用死 code 的实锤);
   *   isLatest   = 是不是该 user 最新签发的那张(false=宿主拿了旧 code;null=查不到);
   *   issuedAt/consumedAt/userId 等身份与时刻。
   */
  private async traceCode(tag: string): Promise<CodeTrace> {
    const empty: CodeTrace = {
      userId: null,
      hostId: null,
      tenantId: null,
      issuedAt: null,
      ageSeconds: null,
      usedBefore: false,
      consumedAt: null,
      isLatest: null,
    };
    try {
      const [issuedRaw, seenRaw] = await Promise.all([
        this.redis.get(CODE_ISSUED_PREFIX + tag),
        this.redis.get(CODE_SEEN_PREFIX + tag),
      ]);
      const t: CodeTrace = { ...empty };
      if (issuedRaw) {
        const p = JSON.parse(issuedRaw) as {
          userId?: string;
          hostId?: string;
          tenantId?: string;
          at?: string;
        };
        t.userId = p.userId ?? null;
        t.hostId = p.hostId ?? null;
        t.tenantId = p.tenantId ?? null;
        t.issuedAt = p.at ?? null;
        if (p.at) {
          const ms = Date.now() - Date.parse(p.at);
          if (!Number.isNaN(ms)) t.ageSeconds = Math.round(ms / 1000);
        }
      }
      if (seenRaw) {
        t.usedBefore = true;
        try {
          const s = JSON.parse(seenRaw) as { at?: string; sub?: string; hostId?: string; tenantId?: string };
          t.consumedAt = s.at ?? null;
          t.userId = t.userId ?? s.sub ?? null; // issued 没记到身份时从 seen 补
          t.hostId = t.hostId ?? s.hostId ?? null;
          t.tenantId = t.tenantId ?? s.tenantId ?? null;
        } catch {
          /* noop */
        }
      }
      if (t.userId) {
        const latest = await this.redis.get(LATEST_CODE_PREFIX + t.userId);
        if (latest) t.isLatest = latest === tag;
      }
      return t;
    } catch {
      return empty;
    }
  }

  /** codeTrace → 一行紧凑日志。 */
  private fmtTrace(t: CodeTrace): string {
    const y = (b: boolean | null) => (b === null ? '?' : b ? '是' : '否');
    return (
      `[足迹 签发=${t.userId ?? '-'}@${t.issuedAt ?? '-'} ` +
      `龄=${t.ageSeconds ?? '?'}s 最新那张=${y(t.isLatest)} ` +
      `已消费过=${y(t.usedBefore)}${t.consumedAt ? '@' + t.consumedAt : ''}]`
    );
  }

  /**
   * 会话失效告警 → 企微监控群(复用 AlertService)。
   * 护栏:①server 端发(webhook URL 是密钥,不进前端);②去重窗仅收敛重复企微(不改鉴权、不丢日志);
   * ③脱敏 —— 只发 codeTag(前 8)、布尔、opaque id,绝不带完整 code / token(全码只在服务端 MISS 日志)。
   * 富信息:反查失效 code 的足迹 —— 是不是最新签发那张、有没有被消费过、签发身份与时刻。
   */
  private async alertSessionExpiry(d: ClientDiagPayload): Promise<void> {
    // 反查失效 code 足迹:最新那张? 消费过? 龄(mint→失败时间差)? 身份?
    const trace = d.codeTag ? await this.traceCode(d.codeTag) : null;
    const usedBefore = trace?.usedBefore ?? false;
    // 不做去重:每次会话失效都推(宿主复用刷爆群 = 排查宿主的信号)。

    // 根因推断:分清"存储分区" / "重载复用死码" / "旧码" / "全新码到手即死" / "嵌入没带 code"
    const storageBlocked = d.storageWritable === false;
    // 全新码到手即死 仅当【确实带了 code】才成立(hadCode=true);否则根本没 code,别乱判成时间差。
    const freshButDead =
      d.hadCode === true &&
      !usedBefore &&
      trace?.isLatest !== false &&
      d.isReload !== true &&
      d.storedRefresh !== true;
    // 运行期掉线(登录后用着用着失效)—— 与"首开换票失败"根因不同,单独判。
    const isRuntime = d.ev === 'runtime-refresh-fail' || d.ev === 'runtime-auth-clear';
    const rootCause = isRuntime
      ? d.ev === 'runtime-refresh-fail'
        ? 'refresh 被拒(refresh token 轮换/过期,或定时刷与请求刷竞态双刷互相作废)→ 运行期掉线'
        : `受保护请求鉴权失败清会话(${d.err ?? '?'})→ 运行期掉线`
      : storageBlocked
      ? '存储不可写(iframe 第三方存储被分区/ITP/隐私模式拦)'
      : usedBefore
        ? '重载复用已消费 code + store 无 token(疑似存储读不到)'
        : trace?.isLatest === false
          ? '用了旧 code(非最新签发)+ store 无 token'
          : freshButDead
            ? `全新最新 code 到手即死(疑似 mint→验证超时/Redis 淘汰;code 龄 ${trace?.ageSeconds ?? '?'}s)`
            : d.hadCode !== true && d.isEmbedded === true
              ? '嵌入内 URL 无 code + store 无 token(宿主(重)载没带 code / 存储读不到)'
              : '无凭据(URL 无 code 且 store 无 token)';
    const y = (b: boolean | null | undefined) => (b === null || b === undefined ? '?' : b ? '是' : '否');

    await this.alert.send({
      level: 'critical',
      title: isRuntime ? '运行期会话掉线(登录已过期)' : '嵌入首开会话失效(登录已过期)',
      body: [
        `事件: ${d.ev}   根因推断: ${rootCause}`,
        `失效code: ${d.codeTag ?? '-'}…  最新那张: ${y(trace?.isLatest)}  已消费过: ${y(usedBefore)}${trace?.consumedAt ? `(${trace.consumedAt})` : ''}`,
        `code 龄: ${trace?.ageSeconds ?? '?'}s(签发→失败间隔;> code TTL 即过期而死)`,
        `签发: ${trace?.userId ?? '-'} @ ${trace?.issuedAt ?? '-'}  (全码见服务端 MISS 日志)`,
        `store: access=${y(d.storedAccess)} refresh=${y(d.storedRefresh)}  存储可写: ${y(d.storageWritable)}`,
        `嵌入: ${y(d.isEmbedded)}  重载: ${y(d.isReload)}  宿主: ${d.hostOrigin ?? '-'}`,
      ].join('\n'),
      context: {
        ev: d.ev,
        codeTag: d.codeTag ?? null,
        codeIsLatest: trace?.isLatest ?? null,
        codeUsedBefore: usedBefore,
        codeAgeSeconds: trace?.ageSeconds ?? null,
        codeIssuedAt: trace?.issuedAt ?? null,
        codeConsumedAt: trace?.consumedAt ?? null,
        storedAccess: Boolean(d.storedAccess),
        storedRefresh: Boolean(d.storedRefresh),
        storageWritable: d.storageWritable ?? null,
        isEmbedded: Boolean(d.isEmbedded),
        isReload: Boolean(d.isReload),
        hostOrigin: d.hostOrigin ?? null,
        ua: d.ua ?? null,
        hostId: trace?.hostId ?? null,
        tenantId: trace?.tenantId ?? null,
        userId: trace?.userId ?? null,
      },
    });
  }

  /**
   * Refresh access token(W3 末 A 方案 — 本地降级,无 host SSO 回调)
   *
   * 流程:
   *   1. 验证 refresh token 签名(独立 secret JWT_REFRESH_SECRET)
   *   2. Redis 校验 jti 是否还在白名单(未被 rotation 作废、未被人工撤销)
   *   3. 用 refresh payload 里的 sub/role/orgScope/dictionary 重签新 access token
   *   4. **Rotation**:作废老 jti,签发新 refresh token(新 jti 入 Redis),
   *      防 refresh token 被偷后无限刷;被偷者一旦用了,真主人下次刷新就因老 jti 被撤销而失败 → 早发现
   *
   * TODO(host SSO 集成,W5+):
   *   生产前需在步骤 3 前回调 host `/users/{userId}` 拉最新 role/clinicIds —
   *   防"用户已禁用 / 权限已降级" 还能继续刷 token 用 7 天。
   *   联调 host SSO 后,把 refresh payload 里的 role/clinicIds 改为"仅缓存,以 host 返回为准"。
   */
  async refresh(refreshToken: string): Promise<RefreshTokenResponse> {
    let payload: RefreshPayload;
    try {
      payload = await this.jwt.verifyAsync<RefreshPayload>(refreshToken, {
        secret: this.config.getOrThrow<string>('jwt.refreshSecret'),
      });
    } catch {
      throw new BizError(ApiCode.AUTH_INVALID_REFRESH_TOKEN);
    }

    const stored = await this.redis.get(REFRESH_PREFIX + payload.jti);
    if (!stored) throw new BizError(ApiCode.AUTH_INVALID_REFRESH_TOKEN, 'refresh token revoked');

    // ── 步骤 3:重签 access token(payload 里有完整 user 上下文)──
    const permissions = this.resolvePermissions(payload.role);
    const accessToken = await this.signAccessToken({
      sub: payload.sub,
      hostId: payload.hostId,
      tenantId: payload.tenantId,
      orgScope: payload.orgScope,
      role: payload.role,
      permissions,
      ...(payload.dictionary ? { dictionary: payload.dictionary } : {}),
    });

    // ── 步骤 4:rotation — 作废老 jti,签发新 refresh token ──
    await this.redis.del(REFRESH_PREFIX + payload.jti);
    const newRefreshToken = await this.signRefreshToken({
      sub: payload.sub,
      hostId: payload.hostId,
      tenantId: payload.tenantId,
      orgScope: payload.orgScope,
      role: payload.role,
      dictionary: payload.dictionary,
    });

    const expiresIn = this.accessExpiresInSeconds;
    return { accessToken, refreshToken: newRefreshToken, expiresIn };
  }

  async signAccessToken(
    payload: Omit<AccessTokenPayload, 'iat' | 'exp'>,
  ): Promise<string> {
    return this.jwt.signAsync(payload, {
      secret: this.config.getOrThrow<string>('jwt.secret'),
      expiresIn: this.accessExpiresInSeconds,
    });
  }

  private async signRefreshToken(input: {
    sub: string;
    hostId: string;
    tenantId: string;
    orgScope: string[];
    role: UserRole;
    dictionary?: TokenDictionary;
  }): Promise<string> {
    const jti = randomCode(16);
    const ttl = parseDurationToSeconds(this.config.getOrThrow<string>('jwt.refreshExpiresIn'));
    const token = await this.jwt.signAsync(
      { ...input, jti },
      {
        secret: this.config.getOrThrow<string>('jwt.refreshSecret'),
        expiresIn: ttl,
      },
    );
    await this.redis.setEx(REFRESH_PREFIX + jti, '1', ttl);
    return token;
  }

  resolvePermissions(role: UserRole): Permission[] {
    return ROLE_PERMISSIONS[role];
  }

  /**
   * 契约 user 上下文 → 签发 access + refresh。**单一真理源** —— exchangeToken(真 host 换票)
   * 与 mockLogin(模拟 host)共用,token 怎么铸只此一处。
   *
   * 设计 B:JWT 只装通用的 `orgScope`(与对外契约同词汇,不泄漏 PAC 内部 schema)。
   * **不在此展开**成 sourceUnits/clinicIds —— 那留到每个请求(tenant-scope 拦截器 / MCP 鉴权)
   * 调 expandScope() 现展开。这样 JWT 解出来跟 host 发进来的一致。
   */
  private async issueTokens(
    host: { id: string; name: string },
    user: {
      userId: string;
      tenantId: string;
      role: UserRole;
      orgScope: string[];
      dictionary?: TokenDictionary;
    },
  ): Promise<{ accessToken: string; refreshToken: string }> {
    const permissions = this.resolvePermissions(user.role);

    const accessToken = await this.signAccessToken({
      sub: user.userId,
      hostId: host.id,
      tenantId: user.tenantId,
      orgScope: user.orgScope, // 数据权限(通用词汇);请求时再展开成过滤维
      role: user.role,
      permissions,
      ...(user.dictionary ? { dictionary: user.dictionary } : {}),
    });
    const refreshToken = await this.signRefreshToken({
      sub: user.userId,
      hostId: host.id,
      tenantId: user.tenantId,
      orgScope: user.orgScope,
      role: user.role,
      dictionary: user.dictionary,
    });
    return { accessToken, refreshToken };
  }

  // ─────────────────────────────────────────────────────────────
  // Mock Login(开发 / 试部署用 — env 门控,生产关闭)
  // ─────────────────────────────────────────────────────────────
  //
  // 用途:试部署期间客服 / 演示用户没真 host SSO 也能登录工作台。
  // 安全:env 门控(NODE_ENV != production 或 PAC_ENABLE_MOCK_LOGIN=true);
  //      生产真 host 接入后,删 endpoint 或把 env 设 false。

  /// jvs-dw 预制 user 数据(集团模型);抽到 jvs-dw-preset.ts,与 org-tree.ts 共用。
  private readonly MOCK_PRESETS = JVS_DW_PRESETS;

  isMockLoginEnabled(): boolean {
    if (process.env.PAC_ENABLE_MOCK_LOGIN === 'true') return true;
    if (process.env.PAC_ENABLE_MOCK_LOGIN === 'false') return false;
    return (this.config.get<string>('nodeEnv') ?? process.env.NODE_ENV) !== 'production';
  }

  async mockLogin(req: MockLoginRequest): Promise<ExchangeCodeResponse> {
    if (!this.isMockLoginEnabled()) {
      throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用');
    }
    const preset = this.MOCK_PRESETS[req.tenant];
    if (!preset) {
      throw new BizError(ApiCode.CLIENT_VALIDATION_FAILED, `unknown tenant: ${req.tenant}`);
    }

    // 找 jvs-dw host(seed 时已建)
    const host = await this.prisma.host.findFirst({ where: { name: 'jvs-dw' } });
    if (!host) {
      throw new BizError(
        ApiCode.HOST_NOT_FOUND,
        '请先 seed jvs-dw host(pnpm --filter @pac/service exec prisma db seed)',
      );
    }

    // 集团模型:数据范围 = orgScope(任意层级);展开留到请求时(设计 B)。
    let sub = `mock-${req.tenant}-${req.role}`;
    let subName = MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role);
    let orgScope: string[];
    // 全集团诊所(集团级登录 / 字典用)
    const allClinics = Object.values(this.MOCK_PRESETS).reduce(
      (acc, p) => Object.assign(acc, p.clinics),
      {} as Record<string, string>,
    );

    if (req.orgLevel === 'group') {
      // ── 集团级:orgScope=[集团根 id] → 展开后 sourceUnits=[]/clinicIds=[] 不限 ──
      sub = `mock-group-${req.role}`;
      subName = `集团${roleNameZh(req.role)}`;
      orgScope = [preset.tenantId];
    } else if (req.userExternalId) {
      // ── 诊所级:具体客服真实身份 + 其所属诊所 ──
      const cs = loadMockUsers().find(
        (u) => u.tenant === req.tenant && u.externalId === req.userExternalId,
      );
      if (!cs) {
        throw new BizError(
          ApiCode.CLIENT_VALIDATION_FAILED,
          `unknown mock user: ${req.tenant}/${req.userExternalId}`,
        );
      }
      sub = cs.externalId;
      subName = cs.name;
      const own = cs.clinics.map((c) => c.clinicId).filter((id) => id in preset.clinics);
      orgScope = own.length > 0 ? own : [preset.brandOrgId]; // 无诊所兜底回品牌级(品牌 GUID)
    } else {
      // ── 品牌级:整个品牌(全部诊所)──
      // 用品牌 GUID(契约=传 id),真正模拟真实宿主换票;org 树靠 host.orgAliases 认它。
      // (旧版本传品牌名"瑞尔",与 PAC 内部树自洽却测不出"宿主传 GUID"这类 bug。)
      orgScope = [preset.brandOrgId];
    }

    const dictionary: TokenDictionary = {
      clinics: (req.orgLevel === 'group' ? allClinics : preset.clinics) as Record<string, string>,
      users: { [sub]: subName },
    };

    // 第一性:模拟登录 = 模拟 host 调换票 —— 产出契约同形的 user 上下文(核心就是 orgScope),
    // 走与 exchangeToken **同一条** issueTokens(展开 + 签发),不另搞一套。
    const { accessToken, refreshToken } = await this.issueTokens(host, {
      userId: sub,
      tenantId: preset.tenantId,
      role: req.role,
      orgScope,
      dictionary,
    });
    const expiresIn = this.accessExpiresInSeconds;

    this.logger.log(
      `mock-login: tenant=${req.tenant}(${preset.tenantNameZh}) role=${req.role} ` +
        `sub=${sub}(${subName}) orgScope=[${orgScope.join(',')}] hostId=${host.id}`,
    );
    return { accessToken, refreshToken, expiresIn };
  }

  /**
   * 客服花名册(给"快速登录"对话框选人用)。
   * 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。
   */
  listMockUsers(): MockUser[] {
    if (!this.isMockLoginEnabled()) return [];
    return loadMockUsers()
      .map((u) => {
        const preset = this.MOCK_PRESETS[u.tenant];
        const clinicDict = (preset?.clinics ?? {}) as Record<string, string>;
        return {
          externalId: u.externalId,
          name: u.name,
          tenant: u.tenant,
          lastActiveAt: u.lastActiveAt,
          contactCount: u.clinics.reduce((s, c) => s + (c.contactCount ?? 0), 0),
          clinics: u.clinics.map((c) => ({
            clinicId: c.clinicId,
            name: clinicDict[c.clinicId] ?? c.clinicId,
          })),
        };
      })
      .sort(
        (a, b) =>
          a.tenant.localeCompare(b.tenant) || b.contactCount - a.contactCount,
      );
  }
}

function roleNameZh(role: UserRole): string {
  return ({ staff: '员工', leader: '主管', admin: '管理员' } as const)[role] ?? role;
}

/// 模拟登录的演示人名(2 brand × 3 role);话术自报家门会用到("我是X诊所的客服小王")。
const MOCK_NAMES: Record<string, Partial<Record<UserRole, string>>> = {
  ruier: { staff: '小王', leader: '李莉', admin: '张敏' },
  ruitai: { staff: '小陈', leader: '周琳', admin: '刘洋' },
};
