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 { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users';

const CODE_PREFIX = 'pac:exchange-code:';
const REFRESH_PREFIX = 'pac:refresh:';

/**
 * 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;
}

@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,
  ) {}

  /** 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,
    );

    const expiresIn = this.accessExpiresInSeconds;

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

  async exchangeCode(code: string): Promise<ExchangeCodeResponse> {
    const raw = await this.redis.getDel(CODE_PREFIX + code);
    if (!raw) throw new BizError(ApiCode.AUTH_INVALID_CODE);
    const parsed = JSON.parse(raw) as { accessToken: string; refreshToken: string };
    const expiresIn = this.accessExpiresInSeconds;
    return {
      accessToken: parsed.accessToken,
      refreshToken: parsed.refreshToken,
      expiresIn,
    };
  }

  /**
   * 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: '刘洋' },
};
