/// <reference lib="dom" />
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
import { logger } from '../utils/logger.js';
import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
import { AccountService } from './AccountService.js';
import { BrowserManager } from '../automation/browser.js';
import type { ProxyConfig } from '@media-manager/shared';
import { CookieManager } from '../automation/cookie.js';
import { WS_EVENTS } from '@media-manager/shared';
import { wsManager } from '../websocket/index.js';
import type { PlatformType } from '@media-manager/shared';

/** 抖音 metrics_trend 返回 user not match / 未登录时抛出，用于触发「先刷新账号、再决定是否账号失效」 */
export class DouyinLoginExpiredError extends Error {
  constructor(message = 'DOUYIN_LOGIN_EXPIRED') {
    super(message);
    this.name = 'DouyinLoginExpiredError';
  }
}

type PlaywrightCookie = {
  name: string;
  value: string;
  domain?: string;
  path?: string;
  url?: string;
  expires?: number;
  httpOnly?: boolean;
  secure?: boolean;
  sameSite?: 'Lax' | 'None' | 'Strict';
};

type TrendPoint = { date_time?: string; value?: string | number };
type MetricsTrendResponse = {
  status_code: number;
  status_msg?: string;
  trend_map?: Record<
    string,
    Record<string, TrendPoint[]>
  >;
};

interface DailyWorkStatPatch {
  workId: number;
  recordDate: Date;
  playCount?: number;
  likeCount?: number;
  commentCount?: number;
  shareCount?: number;
  collectCount?: number;
  fansIncrease?: number;
  completionRate?: string;
  twoSecondExitRate?: string;
}

function tryDecryptCookieData(cookieData: string | null): string | null {
  if (!cookieData) return null;
  const raw = cookieData.trim();
  if (!raw) return null;
  try {
    return CookieManager.decrypt(raw);
  } catch {
    return raw;
  }
}

function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
  const rawOrDecrypted = tryDecryptCookieData(cookieData);
  if (!rawOrDecrypted) return [];
  const raw = rawOrDecrypted.trim();
  if (!raw) return [];

  // 1) JSON array（最常见）
  if (raw.startsWith('[') || raw.startsWith('{')) {
    try {
      const parsed = JSON.parse(raw);
      const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
      if (!Array.isArray(arr)) return [];
      return arr
        .map((c: any) => {
          const name = String(c?.name ?? '').trim();
          const value = String(c?.value ?? '').trim();
          if (!name) return null;
          const domain = c?.domain ? String(c.domain) : undefined;
          const pathVal = c?.path ? String(c.path) : '/';
          const url = !domain ? 'https://creator.douyin.com' : undefined;
          const sameSiteRaw = c?.sameSite;
          const sameSite =
            sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
              ? sameSiteRaw
              : undefined;
          return {
            name,
            value,
            domain,
            path: pathVal,
            url,
            expires: typeof c?.expires === 'number' ? c.expires : undefined,
            httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
            secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
            sameSite,
          } satisfies PlaywrightCookie;
        })
        .filter(Boolean) as PlaywrightCookie[];
    } catch {
      // fallthrough
    }
  }

  // 2) "a=b; c=d" 拼接格式
  const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
  const cookies: PlaywrightCookie[] = [];
  for (const p of pairs) {
    const idx = p.indexOf('=');
    if (idx <= 0) continue;
    const name = p.slice(0, idx).trim();
    const value = p.slice(idx + 1).trim();
    if (!name) continue;
    cookies.push({ name, value, url: 'https://creator.douyin.com' });
  }
  return cookies;
}

async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
  const headless = true;
  if (proxy?.enabled) {
    const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
    const browser = await chromium.launch({
      headless,
      proxy: {
        server,
        username: proxy.username,
        password: proxy.password,
      },
      args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
    });
    return { browser, shouldClose: true };
  }
  const browser = await BrowserManager.getBrowser({ headless });
  return { browser, shouldClose: false };
}

function parseChinaDateFromDateTimeString(dateTime: unknown): Date | null {
  if (!dateTime) return null;
  const s = String(dateTime).trim();
  if (s.length < 10) return null;
  const ymd = s.slice(0, 10); // YYYY-MM-DD
  const m = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (!m) return null;
  const yyyy = Number(m[1]);
  const mm = Number(m[2]);
  const dd = Number(m[3]);
  if (!yyyy || !mm || !dd) return null;
  const d = new Date(yyyy, mm - 1, dd, 0, 0, 0, 0);
  return d;
}

function toNumber(val: unknown, defaultValue = 0): number {
  if (typeof val === 'number') return Number.isFinite(val) ? val : defaultValue;
  if (typeof val === 'string') {
    const n = Number(val);
    return Number.isFinite(n) ? n : defaultValue;
  }
  return defaultValue;
}

function toInt(val: unknown, defaultValue = 0): number {
  const n = toNumber(val, NaN);
  if (!Number.isFinite(n)) return defaultValue;
  return Math.round(n);
}

function normalizePercentString(val: unknown): string | undefined {
  const n = toNumber(val, NaN);
  if (!Number.isFinite(n)) return undefined;
  // 小于等于 0 统一记为 "0"
  if (n <= 0) return '0';
  // 原始值视为 0-1 之间的小数，这里 *100 后四舍五入保留两位小数并加 "%"
  const scaled = n * 100;
  const rounded = Math.round(scaled * 100) / 100;
  const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
  return `${s}%`;
}

/** 平均时长等：保留两位小数，四舍五入 */
function toFixed2String(val: unknown): string | undefined {
  const n = toNonNegativeNumber(val);
  if (n == null) return undefined;
  const rounded = Math.round(n * 100) / 100;
  return rounded.toFixed(2);
}

function isDouyinLoginExpiredByApi(body: any): boolean {
  const code = Number(body?.status_code);
  const msg = String(body?.status_msg || '');
  if (code === 20001) return true; // user not match
  if (msg.includes('user not match')) return true;
  if (msg.includes('登录') && msg.includes('失效')) return true;
  return false;
}

class DouyinMetricsTrendClient {
  private capturedHeaders: Record<string, string> | null = null;

  private buildTrendUrl(itemId: string, metric: string): string {
    const base = 'https://creator.douyin.com/janus/douyin/creator/data/item_analysis/metrics_trend';
    const params = new URLSearchParams({
      aid: '2906',
      app_name: 'aweme_creator_platform',
      device_platform: 'web',
      // referer/user_agent 等埋点参数保留，尽量贴近浏览器请求
      referer: 'https://creator.douyin.com/creator-micro/data-center/content',
      user_agent:
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
      cookie_enabled: 'true',
      screen_width: '1920',
      screen_height: '1080',
      browser_language: 'zh-CN',
      browser_platform: 'Win32',
      browser_name: 'Mozilla',
      browser_version:
        '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
      browser_online: 'true',
      timezone_name: 'Asia/Shanghai',
      item_id: itemId,
      // 按照浏览器抓包使用 trend_type=1，与抖音后台曲线口径保持一致
      trend_type: '1',
      time_unit: '1',
      metrics_group: '0,1,3',
      metrics: metric,
    });
    return `${base}?${params.toString()}`;
  }

  private filterCapturedHeaders(h: Record<string, string>): Record<string, string> {
    const out: Record<string, string> = {};
    const allowList = new Set([
      'accept',
      'accept-language',
      'agw-js-conv',
      'user-agent',
      'x-secsdk-csrf-token',
    ]);
    for (const [k, v] of Object.entries(h || {})) {
      const key = k.toLowerCase();
      if (key === 'cookie') continue;
      if (key === 'host') continue;
      if (key === 'authority') continue;
      if (key === 'content-length') continue;
      if (key === 'referer') continue; // 每次按作品详情页动态设置
      if (allowList.has(key)) out[key] = v;
      // 保留所有 x- 前缀的 header（有些风控字段会放在 x- 里）
      else if (key.startsWith('x-')) out[key] = v;
    }
    return out;
  }

  private async captureHeadersFromRealRequest(page: Page, metricLabel: string, itemId: string, metric: string): Promise<void> {
    // 通过点击 UI 触发一次真实请求，抓取其 headers（尤其是 x-secsdk-csrf-token）
    const apiPattern = /\/janus\/douyin\/creator\/data\/item_analysis\/metrics_trend/i;

    const wait = page.waitForResponse((res) => {
      if (res.request().method() !== 'GET') return false;
      const u = res.url();
      return apiPattern.test(u) && u.includes(`item_id=${itemId}`) && u.includes(`metrics=${metric}`);
    }, { timeout: 25_000 });

    // 指标卡片（如：点赞量/播放量/评论量...）
    // 允许用 "|" 提供多个候选文案（适配 UI 文案差异）
    const labels = metricLabel
      .split('|')
      .map((s) => s.trim())
      .filter(Boolean);

    let clicked = false;
    for (const label of labels.length ? labels : [metricLabel]) {
      const loc = page.getByText(label, { exact: false }).first();
      const cnt = await loc.count().catch(() => 0);
      if (!cnt) continue;
      await loc.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => undefined);
      await loc.click().catch(() => undefined);
      clicked = true;
      break;
    }

    if (!clicked) {
      // 不强制失败：有些情况下 UI 不可点击，但直连请求仍可能成功
      logger.warn(`[DY WorkStats] Could not click metric label on page for header capture. label=${metricLabel}`);
    }

    const res = await wait;
    const headers = res.request().headers();
    this.capturedHeaders = this.filterCapturedHeaders(headers);
    logger.info(`[DY WorkStats] Captured request headers for metrics_trend. keys=${Object.keys(this.capturedHeaders).join(',')}`);
  }

  async fetchTrend(
    ctx: BrowserContext,
    page: Page,
    itemId: string,
    metric: string,
    metricLabelForFallback: string,
    refererUrl: string
  ): Promise<MetricsTrendResponse> {
    const url = this.buildTrendUrl(itemId, metric);

    const headers: Record<string, string> = {
      accept: 'application/json, text/plain, */*',
      'accept-language': 'zh-CN,zh;q=0.9',
      referer: refererUrl,
      'user-agent':
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
      ...(this.capturedHeaders || {}),
    };

    // 1) 先尝试直接请求（最快）
    try {
      const res = await ctx.request.get(url, {
        headers,
        timeout: 25_000,
      });
      const json = (await res.json().catch(() => null)) as MetricsTrendResponse | null;
      if (json && typeof json === 'object') return json;
    } catch {
      // fallthrough
    }

    // 2) 如果直连失败，抓一次真实请求 header 后重试
    if (!this.capturedHeaders) {
      await this.captureHeadersFromRealRequest(page, metricLabelForFallback, itemId, metric).catch(() => undefined);
    }

    const headers2: Record<string, string> = {
      accept: 'application/json, text/plain, */*',
      'accept-language': 'zh-CN,zh;q=0.9',
      referer: refererUrl,
      'user-agent':
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
      ...(this.capturedHeaders || {}),
    };

    const res2 = await ctx.request.get(url, {
      headers: headers2,
      timeout: 25_000,
    });
    const json2 = (await res2.json().catch(() => null)) as MetricsTrendResponse | null;
    if (!json2) throw new Error('metrics_trend 响应不是 JSON');
    return json2;
  }
}

function toNonNegativeNumber(val: unknown): number | undefined {
  const n = toNumber(val, NaN);
  if (!Number.isFinite(n)) return undefined;
  return n < 0 ? 0 : n;
}

/**
 * 比率类：0 不加 "%"（返回 "0"），非 0 时 *100 后四舍五入到两位小数并加 "%"
 * 例如: 0.12345 -> "12.35%", 0.0 -> "0"
 */
function toRatePercentStringFromValue(val: unknown): string | undefined {
  const n = toNumber(val, NaN);
  if (!Number.isFinite(n)) return undefined;
  if (n === 0) return '0';
  const scaled = n * 100;
  const rounded = Math.round(scaled * 100) / 100; // 保留两位小数，四舍五入
  const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
  return `${s}%`;
}

export class DouyinWorkStatisticsImportService {
  private accountRepository = AppDataSource.getRepository(PlatformAccount);
  private workRepository = AppDataSource.getRepository(Work);
  private workDayStatisticsService = new WorkDayStatisticsService();

  static async runDailyImport(): Promise<void> {
    const svc = new DouyinWorkStatisticsImportService();
    await svc.runDailyImportForAllDouyinAccounts();
  }

  async runDailyImportForAllDouyinAccounts(): Promise<void> {
    const accounts = await this.accountRepository.find({
      where: { platform: 'douyin' as any },
    });

    logger.info(`[DY WorkStats] Start import for ${accounts.length} accounts`);

    for (const account of accounts) {
      try {
        await this.importAccountWorksStatistics(account);
      } catch (e) {
        logger.error(
          `[DY WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
          e
        );
        // 单账号失败仅记录日志，不中断循环，其他账号照常同步
      }
    }

    logger.info('[DY WorkStats] All accounts done');
  }

  /**
   * 按账号同步作品日统计。检测到 cookie 失效时：先尝试同步/刷新账号一次；刷新仍失效则标记账号 expired。
   * @param isRetry 是否为「刷新账号后的重试」，避免无限递归
   */
  async importAccountWorksStatistics(
    account: PlatformAccount,
    isRetry = false,
    options?: {
      workIdFilter?: number[];
      onProgress?: (payload: { index: number; total: number; work: Work }) => void;
    }
  ): Promise<void> {
    const cookies = parseCookiesFromAccount(account.cookieData);
    if (!cookies.length) {
      logger.warn(`[DY WorkStats] accountId=${account.id} cookieData 为空或无法解析，跳过`);
      return;
    }

    let works = await this.workRepository.find({
      where: {
        accountId: account.id,
        platform: 'douyin' as any,
      },
    });

    if (options?.workIdFilter && options.workIdFilter.length > 0) {
      const filterSet = new Set(
        options.workIdFilter.map((id) => Number(id)).filter((n) => Number.isFinite(n) && n > 0)
      );
      works = works.filter((w) => filterSet.has(w.id));
    }

    // 同小红书保持一致：按发布时间从近到远处理；无发布时间的排在最后
    if (works.length > 1) {
      works.sort((a, b) => {
        const ta = a.publishTime ? new Date(a.publishTime as any).getTime() : -Infinity;
        const tb = b.publishTime ? new Date(b.publishTime as any).getTime() : -Infinity;
        if (tb !== ta) return tb - ta;
        return (b.id || 0) - (a.id || 0);
      });
    }

    if (!works.length) {
      logger.info(`[DY WorkStats] accountId=${account.id} 没有作品，跳过`);
      return;
    }

    const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
    let context: BrowserContext | null = null;
    let closedDueToLoginExpired = false;
    try {
      context = await browser.newContext({
        viewport: { width: 1920, height: 1080 },
        locale: 'zh-CN',
        timezoneId: 'Asia/Shanghai',
        userAgent:
          'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
      });
      await context.addCookies(cookies as any);
      context.setDefaultTimeout(60_000);

      if (!context) {
        throw new Error('BrowserContext 初始化失败');
      }
      const ctx = context;

      const page = await context.newPage();
      const client = new DouyinMetricsTrendClient();

      let totalInserted = 0;
      let totalUpdated = 0;

      const total = works.length;

      for (let i = 0; i < works.length; i++) {
        const work = works[i];

        if (options?.onProgress) {
          options.onProgress({ index: i + 1, total, work });
        }

        const itemId = (work.platformVideoId || '').trim();
        if (!itemId) continue;

        const detailUrl = `https://creator.douyin.com/creator-micro/work-management/work-detail/${encodeURIComponent(
          itemId
        )}?enter_from=item_data`;

        try {
          await page.goto(detailUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
          await page.waitForTimeout(1200);

          if (page.url().includes('login') || page.url().includes('passport')) {
            throw new DouyinLoginExpiredError('work-detail 页面跳转登录，cookie 可能失效');
          }

          // metrics -> 入库字段映射
          const metricsPlan: Array<{
            metric: string;
            label: string;
            apply: (patch: DailyWorkStatPatch, v: unknown) => void;
          }> = [
            { metric: 'view_count', label: '播放量', apply: (p, v) => (p.playCount = toInt(v, 0)) },
            { metric: 'like_count', label: '点赞量', apply: (p, v) => (p.likeCount = toInt(v, 0)) },
            { metric: 'comment_count', label: '评论量', apply: (p, v) => (p.commentCount = toInt(v, 0)) },
            { metric: 'share_count', label: '分享量', apply: (p, v) => (p.shareCount = toInt(v, 0)) },
            { metric: 'favorite_count', label: '收藏量|收藏数', apply: (p, v) => (p.collectCount = toInt(v, 0)) },
            { metric: 'subscribe_count', label: '涨粉量|涨粉数', apply: (p, v) => (p.fansIncrease = toInt(v, 0)) },
            {
              metric: 'completion_rate',
              label: '完播率',
              apply: (p, v) => {
                const s = normalizePercentString(v);
                if (s != null) p.completionRate = s;
              },
            },
            {
              metric: 'bounce_rate_2s',
              label: '2s退出率|2s跳出率|2s跳出',
              apply: (p, v) => {
                const s = normalizePercentString(v);
                if (s != null) p.twoSecondExitRate = s;
              },
            },
          ];

          const dayMap = new Map<number, DailyWorkStatPatch>();

          for (const m of metricsPlan) {
            const body = await client.fetchTrend(ctx, page, itemId, m.metric, m.label, detailUrl);
            if (!body || typeof body !== 'object') continue;

            // 调试：把 metrics_trend 原始返回打印成 JSON，方便和抖音后台对比
            if (work.id === 39 && m.metric === 'completion_rate') {
              try {
                logger.info(
                  `[DY WorkStats][debug metrics_trend raw] workId=${work.id} itemId=${itemId} metric=${m.metric} body=${JSON.stringify(
                    body
                  )}`
                );
              } catch {
                // ignore JSON stringify error
              }
            }

            if (isDouyinLoginExpiredByApi(body)) {
              throw new DouyinLoginExpiredError(body.status_msg || 'metrics_trend: user not match');
            }
            if (Number(body.status_code) !== 0) {
              logger.warn(
                `[DY WorkStats] metrics_trend 非成功返回. accountId=${account.id} workId=${work.id} itemId=${itemId} metric=${m.metric} code=${body.status_code} msg=${body.status_msg || ''}`
              );
              continue;
            }

            const trendMap = body.trend_map || {};
            const metricMap = (trendMap as any)[m.metric] as Record<string, TrendPoint[]> | undefined;
            if (!metricMap) continue;

            // 优先取 group "0"（一般为“总计/全部”），否则兜底合并全部 group
            const points = Array.isArray(metricMap['0'])
              ? metricMap['0']
              : Object.values(metricMap).flatMap((arr) => (Array.isArray(arr) ? arr : []));

            // 调试：打印 workId=39 的 completion_rate 全量 points，确认与抖音后台返回是否一致
            if (work.id === 39 && m.metric === 'completion_rate') {
              try {
                logger.info(
                  `[DY WorkStats][debug completion_rate points] workId=${work.id} itemId=${itemId} points=${JSON.stringify(
                    points
                  )}`
                );
              } catch {
                // ignore JSON stringify error
              }
            }

            for (const pt of points) {
              // 调试：打印指定作品的完播率原始值
              if (work.id === 39 && m.metric === 'completion_rate') {
                logger.info(
                  `[DY WorkStats][debug completion_rate] workId=${work.id} itemId=${itemId} date=${pt?.date_time} raw_value=${pt?.value}`
                );
              }

              const d = parseChinaDateFromDateTimeString(pt?.date_time);
              if (!d) continue;
              const key = d.getTime();
              let entry = dayMap.get(key);
              if (!entry) {
                entry = { workId: work.id, recordDate: d };
                dayMap.set(key, entry);
              }
              m.apply(entry, pt?.value);
            }
          }

          const patches = Array.from(dayMap.values()).sort(
            (a, b) => a.recordDate.getTime() - b.recordDate.getTime()
          );
          if (!patches.length) continue;

          // 同时补充作品级昨日快照（works.yesterday_*），使用 item/mget metrics
          try {
            await this.applyWorkSnapshotFromItemMget(ctx, itemId, detailUrl, work.id);
          } catch (e) {
            logger.warn(
              `[DY WorkStats] Failed to update works snapshot from item/mget. accountId=${account.id} workId=${work.id} itemId=${itemId}`,
              e
            );
          }

          const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
            patches.map((p) => ({
              workId: p.workId,
              recordDate: p.recordDate,
              playCount: p.playCount,
              likeCount: p.likeCount,
              commentCount: p.commentCount,
              shareCount: p.shareCount,
              collectCount: p.collectCount,
              fansIncrease: p.fansIncrease,
              completionRate: p.completionRate,
              twoSecondExitRate: p.twoSecondExitRate,
            }))
          );

          totalInserted += result.inserted;
          totalUpdated += result.updated;
        } catch (e) {
          if (e instanceof DouyinLoginExpiredError) {
            closedDueToLoginExpired = true;
            if (context) {
              await context.close().catch(() => undefined);
              context = null;
            }
            if (shouldClose) {
              await browser.close().catch(() => undefined);
            }

            // cookie 过期处理：先刷新一次账号，再决定是否标记 expired
            if (!isRetry) {
              logger.info(`[DY WorkStats] accountId=${account.id} 登录失效，尝试同步账号后重试...`);
              try {
                const accountService = new AccountService();
                const refreshResult = await accountService.refreshAccount(account.userId, account.id);
                if (refreshResult.needReLogin) {
                  await this.markAccountExpired(account, 'cookie 过期，需要重新登录');
                  return;
                }
                const refreshed = await this.accountRepository.findOne({ where: { id: account.id } });
                if (refreshed) {
                  logger.info(`[DY WorkStats] accountId=${account.id} 同步账号成功，重新拉取作品数据`);
                  return this.importAccountWorksStatistics(refreshed, true);
                }
              } catch (refreshErr) {
                logger.error(`[DY WorkStats] accountId=${account.id} 同步账号失败`, refreshErr);
                await this.markAccountExpired(account, '同步账号失败，已标记过期');
                return;
              }
            } else {
              await this.markAccountExpired(account, '同步后仍失效，已标记过期');
              return;
            }
          }

          logger.error(
            `[DY WorkStats] Failed to import work stats. accountId=${account.id} workId=${work.id} itemId=${itemId}`,
            e
          );
        }
      }

      logger.info(
        `[DY WorkStats] accountId=${account.id} completed. inserted=${totalInserted}, updated=${totalUpdated}`
      );
    } finally {
      if (!closedDueToLoginExpired) {
        if (context) {
          await context.close().catch(() => undefined);
        }
        if (shouldClose) {
          await browser.close().catch(() => undefined);
        }
      }
    }
  }

  /**
   * 使用 item/mget 接口为 works 表补充昨日快照（yesterday_* 字段）
   */
  private async applyWorkSnapshotFromItemMget(
    ctx: BrowserContext,
    itemId: string,
    refererUrl: string,
    workId: number
  ): Promise<void> {
    const url = `https://creator.douyin.com/web/api/creator/item/mget?ids=${encodeURIComponent(
      itemId
    )}&fields=metrics%2Creview%2Cplay_info`;

    const headers: Record<string, string> = {
      accept: '*/*',
      'accept-language': 'zh-CN,zh;q=0.9',
      referer: refererUrl,
      'user-agent':
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
    };

    const res = await ctx.request.get(url, { headers, timeout: 25_000 });
    const body = (await res.json().catch(() => null)) as any;
    if (!body || typeof body !== 'object' || Number(body.status_code) !== 0) {
      return;
    }

    const items = Array.isArray(body.items) ? body.items : [];
    const first = items[0];
    if (!first || typeof first !== 'object') return;

    const metrics = first.metrics || {};
    const patch: Partial<Work> = {};

    const viewCount = toNonNegativeNumber(metrics.view_count);
    if (viewCount != null) (patch as any).yesterdayPlayCount = Math.trunc(viewCount);

    const likeCount = toNonNegativeNumber(metrics.like_count);
    if (likeCount != null) (patch as any).yesterdayLikeCount = Math.trunc(likeCount);

    const commentCount = toNonNegativeNumber(metrics.comment_count);
    if (commentCount != null) (patch as any).yesterdayCommentCount = Math.trunc(commentCount);

    const shareCount = toNonNegativeNumber(metrics.share_count);
    if (shareCount != null) (patch as any).yesterdayShareCount = Math.trunc(shareCount);

    const collectCount = toNonNegativeNumber(metrics.favorite_count);
    if (collectCount != null) (patch as any).yesterdayCollectCount = Math.trunc(collectCount);

    const fansIncrease = toNonNegativeNumber(metrics.subscribe_count);
    if (fansIncrease != null) (patch as any).yesterdayFansIncrease = Math.trunc(fansIncrease);

    // 平均观看时长（秒）：保留两位小数
    const avgWatchStr = toFixed2String(metrics.avg_view_second);
    if (avgWatchStr != null) (patch as any).yesterdayAvgWatchDuration = avgWatchStr;

    const completionRateStr = toRatePercentStringFromValue(metrics.completion_rate);
    if (completionRateStr != null) (patch as any).yesterdayCompletionRate = completionRateStr;

    const twoSecondExitRateStr = toRatePercentStringFromValue(metrics.bounce_rate_2s);
    if (twoSecondExitRateStr != null) (patch as any).yesterdayTwoSecondExitRate = twoSecondExitRateStr;

    const completion5sStr = toRatePercentStringFromValue(metrics.completion_rate_5s);
    if (completion5sStr != null) (patch as any).yesterdayCompletionRate5s = completion5sStr;

    if (Object.keys(patch).length === 0) return;
    await this.workRepository.update(workId, patch as any);
  }

  private async markAccountExpired(account: PlatformAccount, reason: string): Promise<void> {
    await this.accountRepository.update(account.id, { status: 'expired' as any });
    wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
      account: { id: account.id, status: 'expired', platform: 'douyin' as PlatformType },
    });
    wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
      level: 'warning',
      message: `抖音账号「${account.accountName || account.accountId || account.id}」登录已失效：${reason}`,
      platform: 'douyin',
      accountId: account.id,
    });
  }
}

