package com.fs.feishu.service;

import cn.hutool.json.JSONUtil;
import com.fs.common.core.redis.RedisCache;
import com.fs.common.exception.CustomException;
import com.fs.course.config.CourseConfig;
import com.fs.course.domain.FsCourseLink;
import com.fs.course.domain.FsUserCourseVideo;
import com.fs.course.mapper.FsCourseLinkMapper;
import com.fs.course.mapper.FsUserCourseVideoMapper;
import com.fs.feishu.domain.FeishuAccount;
import com.fs.feishu.mapper.FeishuAccountMapper;
import com.fs.feishu.service.IFeishuAccountService;
import com.fs.feishu.model.FeishuClientHolder;
import com.fs.feishu.model.FeishuLinkParam;
import com.fs.feishu.util.SecureTokenUtil;
import com.fs.system.service.ISysConfigService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * 飞书业务编排层：按销售绑定的飞书账号创建文档链接。
 */
@Component
@Slf4j
public class FeiShuService {

    @Autowired
    private ISysConfigService configService;
    @Autowired
    private FsUserCourseVideoMapper videoMapper;
    @Autowired
    private FsCourseLinkMapper courseLinkMapper;
    @Autowired
    private RedisCache redisCache;
    @Autowired
    private FeishuClientPool clientPool;
    @Autowired
    private FeishuDocApiService docApiService;
    @Autowired
    private FeishuAccountMapper feishuAccountMapper;
    @Autowired
    private IFeishuAccountService feishuAccountService;

    private static final String FEISHU_DOC_URL_PREFIX = "https://www.feishu.cn/docx/";
    private static final String FEISHU_DIRECT_DOC_CACHE_PREFIX = "feishu:direct_doc:";
    private static final String FEISHU_DIRECT_DOC_LOCK_PREFIX = "feishu:direct_doc:lock:";
    /** 当天已创建的免授权文档 cacheKey 索引（Redis Set），供失效时精确删除 */
    private static final String FEISHU_DIRECT_DOC_INDEX_PREFIX = "feishu:direct_doc:index:";
    private static final DateTimeFormatter DIRECT_DOC_DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
    /**
     * 抢锁最长等待。介于原 30s 与激进 3s 之间：兼顾飞书建文档耗时与 Redis 压力。
     * 超时后快速失败，由上层记录失败日志，避免拖垮 Redis CPU。
     */
    private static final long DIRECT_DOC_LOCK_WAIT_MS = 8_000L;
    /** 等待期间本地休眠间隔；仅在休眠后探测一次缓存/锁，不再 200ms 狂刷 */
    private static final long DIRECT_DOC_LOCK_POLL_MS = 500L;
    private static final long DIRECT_DOC_LOCK_TTL_MINUTES = 2L;
    private static final int DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES = 3;
    /**
     * 索引缺失时的分片探测上界（含）。分片从 0 连续分配，遇到连续空分片即停止。
     * 仅兼容上线索引前旧缓存；有索引后按实际分片删除。
     */
    private static final int DIRECT_DOC_INVALIDATE_MAX_SHARD = 200;
    /** 连续若干分片都不存在则停止探测（分片连续分配，无需继续往后扫） */
    private static final int DIRECT_DOC_INVALIDATE_STOP_AFTER_MISS = 2;
    /** 批量 DEL 分批大小，避免单次命令 key 过多 */
    private static final int DIRECT_DOC_DELETE_BATCH_SIZE = 200;
    private static final long[] DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS = {500L, 1000L, 2000L};

    /**
     * 按发课配置生成飞书文档链接：需要授权走注册链，免授权直接生成看课文档。
     */
    public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
                                        Long companyUserId, String shortLink, Long feishuAccountId,
                                        Integer feishuNeedAuth) {
        return resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
                feishuNeedAuth, null, null);
    }

    /**
     * @param directDocShard SOP 群发/自动发课分片序号，null 表示非分片场景
     */
    public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
                                        Long companyUserId, String shortLink, Long feishuAccountId,
                                        Integer feishuNeedAuth, Integer directDocShard) {
        return resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
                feishuNeedAuth, directDocShard, null);
    }

    /**
     * @param directDocShard SOP 分片序号，null 表示非分片
     * @param fsUserId       兼容参数；免授权(17)批量复用时传 null，iframe 固定 userId=0
     */
    public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
                                        Long companyUserId, String shortLink, Long feishuAccountId,
                                        Integer feishuNeedAuth, Integer directDocShard, Long fsUserId) {
        if (isAuthRequired(feishuNeedAuth)) {
            return getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId);
        }
        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
                directDocShard, fsUserId);
    }

    /**
     * 是否需要客户 wxauth 授权后才可看课。null/非0 均视为需要授权（兼容历史数据）。
     */
    public static boolean isAuthRequired(Integer feishuNeedAuth) {
        return feishuNeedAuth == null || feishuNeedAuth != 0;
    }

    /**
     * 持久化到 fs_course_link / course JSON 的值：1-需要授权，0-免授权。
     */
    public static int toStoredFeishuNeedAuth(Integer feishuNeedAuth) {
        return isAuthRequired(feishuNeedAuth) ? 1 : 0;
    }

    /**
     * 是否记录看课数据（时长/看课日志等）。免授权模式不记录。
     */
    public boolean isWatchDataEnabled(String linkCode) {
        if (StringUtils.isBlank(linkCode)) {
            return true;
        }
        FsCourseLink link = courseLinkMapper.selectFsCourseLinkByLink(linkCode);
        if (link == null) {
            return true;
        }
        return isAuthRequired(link.getFeishuNeedAuth());
    }

    /**
     * 短链是否已过有效期（fs_course_link.update_time，通常为发课日当天 23:59:59）。
     */
    public static boolean isCourseLinkExpired(FsCourseLink link) {
        if (link == null || link.getUpdateTime() == null) {
            return false;
        }
        return new Date().after(link.getUpdateTime());
    }

    /**
     * 校验飞书 SOP 免授权短链：存在、免授权、未过期。
     *
     * @return null 表示通过，否则为错误信息
     */
    public String validateNoAuthCourseLink(String linkCode) {
        if (StringUtils.isBlank(linkCode)) {
            return "link不能为空";
        }
        FsCourseLink link = courseLinkMapper.selectFsCourseLinkByLink(linkCode);
        if (link == null) {
            return "链接已过期";
        }
        if (isAuthRequired(link.getFeishuNeedAuth())) {
            return "该链接需要授权，请走授权看课流程";
        }
        if (isCourseLinkExpired(link)) {
            return "链接已过期";
        }
        return null;
    }

    /**
     * 生成飞书免授权看课链接（发课时直接嵌入播放页 iframe 文档）。
     */
    public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                            Long companyUserId, String shortLink, Long feishuAccountId) {
        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId, null, null);
    }

    /**
     * @param directDocShard SOP 分片序号，null 时按销售+课程+账号+日期复用同一文档
     */
    public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                            Long companyUserId, String shortLink, Long feishuAccountId,
                                            Integer directDocShard) {
        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
                directDocShard, null);
    }

    /**
     * 生成飞书免授权看课链接（发课时直接嵌入播放页 iframe 文档）。
     * contentType=17 一批客户共用链接：缓存按销售+课节+账号+天+分片复用；fsUserId 对免授权复用不生效（iframe 固定 0）。
     *
     * @param fsUserId 兼容旧调用，免授权批量场景传 null 即可
     */
    public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                            Long companyUserId, String shortLink, Long feishuAccountId,
                                            Integer directDocShard, Long fsUserId) {
        CourseConfig config = getCourseConfig();
        if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
            throw new CustomException("未开启飞书看课");
        }
        if (companyUserId == null) {
            throw new CustomException("销售ID不能为空");
        }
        // 指定账号若已禁用，改选最新启用账号（避免被封 appId 无法建链）
        FeishuAccount activeAccount = feishuAccountService.resolveEnabledOrLatestAccount(companyUserId, feishuAccountId);
        feishuAccountId = activeAccount.getId();

        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
        if (userCourseVideo == null) {
            throw new CustomException("视频不存在: " + videoId);
        }
        int questionFlag = resolveQuestionFlag(userCourseVideo);
        long embedUserId = fsUserId != null ? fsUserId : 0L;

        List<FeishuAccount> accounts = clientPool.listEnabledAccounts(companyUserId, feishuAccountId);
        CustomException lastError = null;
        for (FeishuAccount account : accounts) {
            FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
            try {
                String docUrl = getOrCreateDirectCourseDocUrl(holder, account.getId(), companyUserId, companyId,
                        courseId, videoId, shortLink, questionFlag, userCourseVideo.getTitle(), directDocShard,
                        embedUserId);
                updateCourseLinkFeishuAccount(shortLink, account.getId());
                return docUrl;
            } catch (CustomException e) {
                lastError = e;
                if (isAccountUnavailable(e)) {
                    log.warn("销售[{}]飞书账号[{}]不可用，尝试下一个", companyUserId, account.getId());
                    continue;
                }
                throw e;
            } catch (Exception e) {
                throw new CustomException("创建飞书免授权看课链接失败: " + e.getMessage(), e);
            }
        }
        if (lastError != null) {
            throw lastError;
        }
        throw new CustomException("当前销售没有可用的飞书账号，请先配置或更换账号");
    }

    /**
     * 手动飞书发课：每次新建飞书文档（风控后直接重新生成即可）。
     */
    public String createManualFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                                     Long companyUserId, String shortLink) {
        CourseConfig config = getCourseConfig();
        if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
            throw new CustomException("未开启飞书看课");
        }
        if (companyUserId == null) {
            throw new CustomException("销售ID不能为空");
        }
        Long feishuAccountId = feishuAccountService.resolveSendAccountId(companyUserId, null);

        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
        if (userCourseVideo == null) {
            throw new CustomException("视频不存在: " + videoId);
        }
        int questionFlag = resolveQuestionFlag(userCourseVideo);

        List<FeishuAccount> accounts = clientPool.listEnabledAccounts(companyUserId, feishuAccountId);
        CustomException lastError = null;
        for (FeishuAccount account : accounts) {
            FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
            try {
                String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, 0L, shortLink, questionFlag);
                String documentId = createDirectCourseDocumentWithRetry(holder, userCourseVideo.getTitle(), iframeUrl);
                feishuAccountMapper.incrementNumberUse(account.getId());
                updateCourseLinkFeishuAccount(shortLink, account.getId());
                String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
                log.info("手动飞书发课新建文档: companyUserId={}, videoId={}, docUrl={}",
                        companyUserId, videoId, resultUrl);
                return resultUrl;
            } catch (CustomException e) {
                lastError = e;
                if (isAccountUnavailable(e)) {
                    log.warn("销售[{}]飞书账号[{}]不可用，尝试下一个", companyUserId, account.getId());
                    continue;
                }
                throw e;
            } catch (Exception e) {
                throw new CustomException("创建飞书免授权看课链接失败: " + e.getMessage(), e);
            }
        }
        if (lastError != null) {
            throw lastError;
        }
        throw new CustomException("当前销售没有可用的飞书账号，请先配置或更换账号");
    }

    /**
     * 生成飞书初始链接（注册授权链接）。
     */
    public String getFeishuRegisterLink(Long videoId, Long companyId, Long courseId,
                                        Long companyUserId, String shortLink, Long feishuAccountId) {
        CourseConfig config = getCourseConfig();
        if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
            throw new CustomException("未开启飞书看课");
        }
        if (companyUserId == null) {
            throw new CustomException("销售ID不能为空");
        }
        feishuAccountId = feishuAccountService.resolveSendAccountId(companyUserId, feishuAccountId);

        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
        if (userCourseVideo == null) {
            throw new CustomException("视频不存在: " + videoId);
        }

        List<FeishuAccount> accounts = clientPool.listEnabledAccounts(companyUserId, feishuAccountId);
        CustomException lastError = null;
        for (FeishuAccount account : accounts) {
            FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
            try {
                String documentId = docApiService.createDocument(holder, userCourseVideo.getTitle() + "-注册");
                String authUrl = buildAuthLink(companyId, companyUserId, courseId, videoId, shortLink);
                docApiService.createTextLinkBlock(holder, documentId, authUrl);
                docApiService.changeDocumentPermissions(holder, documentId);
                feishuAccountMapper.incrementNumberUse(account.getId());
                updateCourseLinkFeishuAccount(shortLink, account.getId());
                return "https://www.feishu.cn/docx/" + documentId;
            } catch (CustomException e) {
                lastError = e;
                if (isAccountUnavailable(e)) {
                    log.warn("销售[{}]飞书账号[{}]不可用，尝试下一个", companyUserId, account.getId());
                    continue;
                }
                throw e;
            } catch (Exception e) {
                throw new CustomException("创建飞书注册链接失败: " + e.getMessage(), e);
            }
        }
        if (lastError != null) {
            throw lastError;
        }
        throw new CustomException("当前销售没有可用的飞书账号，请先配置或更换账号");
    }

    /**
     * 生成飞书课程链接（授权后看课文档）。
     */
    public String getFeishuCourseNewLink(FeishuLinkParam param) {
        if (param == null || param.getCompanyUserId() == null || param.getCompanyId() == null) {
            throw new CustomException("参数不能为空");
        }
        if (StringUtils.isBlank(param.getLink())) {
            throw new CustomException("飞书课程短链不能为空");
        }

        FsCourseLink courseLink = requireCourseLink(param.getLink());
        if (!isAuthRequired(courseLink.getFeishuNeedAuth())) {
            throw new CustomException("免授权链接无需调用此接口");
        }
        // 指定账号若已禁用，改选该销售最新启用账号并回写短链，避免继续用被封 appId
        FeishuAccount activeAccount = feishuAccountService.resolveEnabledOrLatestAccount(
                param.getCompanyUserId(), courseLink.getFeishuAccountId());
        Long feishuAccountId = activeAccount.getId();
        if (courseLink.getFeishuAccountId() == null || !feishuAccountId.equals(courseLink.getFeishuAccountId())) {
            log.info("飞书看课账号切换: link={}, oldAccountId={}, newAccountId={}, appId={}",
                    param.getLink(), courseLink.getFeishuAccountId(), feishuAccountId, activeAccount.getAppId());
            updateCourseLinkFeishuAccount(param.getLink(), feishuAccountId);
        }

        // 领取层已按 link+userId 缓存；此处再建一层：link + userId，保证 17/18 群共享短链按人隔离
        String cacheKey = String.format("feishu:course_link:%s:%d",
                param.getLink(), param.getUserId() != null ? param.getUserId() : 0L);

        String cached = redisCache.getCacheObject(cacheKey);
        if (StringUtils.isNotBlank(cached)) {
            return cached;
        }

        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
        if (userCourseVideo == null) {
            throw new CustomException("视频不存在: " + param.getVideoId());
        }

        int questionFlag = resolveQuestionFlag(userCourseVideo);

        FeishuClientHolder holder = clientPool.getClientByAccountId(feishuAccountId);
        try {
            String documentId = docApiService.createDocument(holder, userCourseVideo.getTitle());
            String iframeUrl = buildCoursePageLink(param.getCompanyId(), param.getCompanyUserId(),
                    param.getCourseId(), param.getVideoId(), param.getUserId(), param.getLink(), questionFlag);
            docApiService.createIframeBlock(holder, documentId, iframeUrl);
            docApiService.changeDocumentPermissions(holder, documentId);

            String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
            redisCache.setCacheObject(cacheKey, resultUrl, 24, TimeUnit.HOURS);
            feishuAccountMapper.incrementNumberUse(feishuAccountId);
            return resultUrl;
        } catch (CustomException e) {
            throw e;
        } catch (Exception e) {
            throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
        }
    }

    /**
     * 同一销售、同一节课、同一飞书账号、同一天（及分片）内复用免授权飞书文档。
     * contentType=17：一批客户共用一个飞书链接，iframe 固定 userId=0（免授权不按人隔离）。
     */
    private String getOrCreateDirectCourseDocUrl(FeishuClientHolder holder, Long feishuAccountId,
                                                 Long companyUserId, Long companyId, Long courseId,
                                                 Long videoId, String shortLink, int questionFlag,
                                                 String documentTitle, Integer directDocShard,
                                                 long embedUserId) throws Exception {
        // 免授权批量复用：缓存维度不含 shortLink/userId；embedUserId 入参保留兼容，建文档时固定用 0
        String cacheKey = buildDirectDocCacheKey(companyUserId, videoId, courseId, feishuAccountId, directDocShard);
        String cached = redisCache.getCacheObject(cacheKey);
        if (StringUtils.isNotBlank(cached)) {
            log.debug("复用飞书免授权文档: companyUserId={}, videoId={}, accountId={}, shard={}, docUrl={}",
                    companyUserId, videoId, feishuAccountId, directDocShard, cached);
            return cached;
        }

        String lockKey = FEISHU_DIRECT_DOC_LOCK_PREFIX + cacheKey;
        boolean locked = false;
        try {
            // 先抢一次锁；抢不到则本地 sleep，再少量探测，避免高频 GET+SETNX 打满 Redis
            locked = Boolean.TRUE.equals(redisCache.setIfAbsent(lockKey, "1",
                    DIRECT_DOC_LOCK_TTL_MINUTES, TimeUnit.MINUTES));
            if (!locked) {
                long deadline = System.currentTimeMillis() + DIRECT_DOC_LOCK_WAIT_MS;
                while (System.currentTimeMillis() < deadline) {
                    Thread.sleep(DIRECT_DOC_LOCK_POLL_MS);
                    cached = redisCache.getCacheObject(cacheKey);
                    if (StringUtils.isNotBlank(cached)) {
                        return cached;
                    }
                    locked = Boolean.TRUE.equals(redisCache.setIfAbsent(lockKey, "1",
                            DIRECT_DOC_LOCK_TTL_MINUTES, TimeUnit.MINUTES));
                    if (locked) {
                        break;
                    }
                }
            }

            if (!locked) {
                cached = redisCache.getCacheObject(cacheKey);
                if (StringUtils.isNotBlank(cached)) {
                    return cached;
                }
                throw new CustomException("飞书文档生成繁忙，请稍后重试");
            }

            cached = redisCache.getCacheObject(cacheKey);
            if (StringUtils.isNotBlank(cached)) {
                return cached;
            }

            // 免授权共用文档：iframe userId 固定 0（与历史非授权逻辑一致）
            String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, 0L, shortLink, questionFlag);
            String documentId = createDirectCourseDocumentWithRetry(holder, documentTitle, iframeUrl);
            String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
            int ttlSeconds = getDirectDocCacheTtlSeconds();
            redisCache.setCacheObject(cacheKey, resultUrl, ttlSeconds, TimeUnit.SECONDS);
            // 写入分片索引，供一键群发失效时按实际分片精确删除（不依赖 SCAN / 固定上界）
            registerDirectDocCacheKey(cacheKey, companyUserId, videoId, courseId, feishuAccountId, ttlSeconds);
            feishuAccountMapper.incrementNumberUse(feishuAccountId);
            log.info("创建并缓存飞书免授权文档: companyUserId={}, videoId={}, accountId={}, shard={}, shortLink={}, docUrl={}",
                    companyUserId, videoId, feishuAccountId, directDocShard, shortLink, resultUrl);
            return resultUrl;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new CustomException("飞书文档生成被中断", e);
        } finally {
            if (locked) {
                redisCache.deleteObject(lockKey);
            }
        }
    }

    /**
     * 一键群发前清除「该销售 + 该课节 + 该飞书账号 + 当天」的免授权文档缓存（含分片与 lock），
     * 避免复用可能已被风控的旧链接。不会清其他销售/其他课的缓存。
     * <p>
     * 优先按索引 Set 精确删除；无索引时按连续分片探测后精确 DEL。全程不使用 SCAN/KEYS。
     */
    public void invalidateDirectDocCache(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
        if (companyUserId == null || videoId == null) {
            return;
        }
        Long resolvedAccountId = feishuAccountService.resolveSendAccountId(companyUserId, feishuAccountId);
        Long courseIdVal = courseId != null ? courseId : 0L;
        String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
        String baseKey = String.format("%s%d:%d:%d:%d:%s",
                FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, resolvedAccountId, dateKey);
        String indexKey = buildDirectDocIndexKey(companyUserId, videoId, courseIdVal, resolvedAccountId, dateKey);

        ArrayList<String> keysToDelete = new ArrayList<>();
        Set<?> indexedKeys = redisCache.getCacheSet(indexKey);
        boolean usedIndex = indexedKeys != null && !indexedKeys.isEmpty();
        if (usedIndex) {
            for (Object member : indexedKeys) {
                if (member == null) {
                    continue;
                }
                String docKey = String.valueOf(member);
                if (StringUtils.isBlank(docKey)) {
                    continue;
                }
                keysToDelete.add(docKey);
                keysToDelete.add(FEISHU_DIRECT_DOC_LOCK_PREFIX + docKey);
            }
            keysToDelete.add(indexKey);
        } else {
            // 索引缺失（兼容旧缓存）：只删真实存在的 key，分片从 0 连续探测，遇空即停
            collectExistingDirectDocKeys(baseKey, keysToDelete);
        }

        if (keysToDelete.isEmpty()) {
            log.info("一键群发刷新飞书文档缓存：无命中 key, companyUserId={}, videoId={}, courseId={}, accountId={}",
                    companyUserId, videoId, courseIdVal, resolvedAccountId);
            return;
        }

        long deleted = deleteDirectDocKeysInBatches(keysToDelete);
        log.info("一键群发刷新飞书文档缓存：已清除 {} 个 key, usedIndex={}, companyUserId={}, videoId={}, courseId={}, accountId={}",
                deleted, usedIndex, companyUserId, videoId, courseIdVal, resolvedAccountId);
    }

    /**
     * 无索引时的精确删除：EXISTS 探测 base + 连续 shard，仅收集存在的 doc/lock key。
     */
    private void collectExistingDirectDocKeys(String baseKey, List<String> keysToDelete) {
        addDirectDocKeyIfExists(baseKey, keysToDelete);
        int missStreak = 0;
        for (int shard = 0; shard <= DIRECT_DOC_INVALIDATE_MAX_SHARD; shard++) {
            String shardKey = baseKey + ":" + shard;
            boolean existed = addDirectDocKeyIfExists(shardKey, keysToDelete);
            if (existed) {
                missStreak = 0;
            } else {
                missStreak++;
                if (missStreak >= DIRECT_DOC_INVALIDATE_STOP_AFTER_MISS) {
                    break;
                }
            }
        }
    }

    /**
     * @return true 表示 doc 或 lock 至少有一个存在并已加入删除列表
     */
    private boolean addDirectDocKeyIfExists(String docKey, List<String> keysToDelete) {
        String lockKey = FEISHU_DIRECT_DOC_LOCK_PREFIX + docKey;
        boolean docExists = redisCache.hasKey(docKey);
        boolean lockExists = redisCache.hasKey(lockKey);
        if (docExists) {
            keysToDelete.add(docKey);
        }
        if (lockExists) {
            keysToDelete.add(lockKey);
        }
        return docExists || lockExists;
    }

    /**
     * 将新创建的文档 cacheKey 登记到当天维度的索引 Set。
     */
    private void registerDirectDocCacheKey(String cacheKey, Long companyUserId, Long videoId, Long courseId,
                                           Long feishuAccountId, int ttlSeconds) {
        Long courseIdVal = courseId != null ? courseId : 0L;
        String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
        String indexKey = buildDirectDocIndexKey(companyUserId, videoId, courseIdVal, feishuAccountId, dateKey);
        redisCache.setCacheSet(indexKey, Collections.singleton(cacheKey));
        redisCache.expire(indexKey, ttlSeconds, TimeUnit.SECONDS);
    }

    private String buildDirectDocIndexKey(Long companyUserId, Long videoId, Long courseIdVal,
                                          Long feishuAccountId, String dateKey) {
        return String.format("%s%d:%d:%d:%d:%s",
                FEISHU_DIRECT_DOC_INDEX_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey);
    }

    /**
     * 分批删除，避免单次 DEL 的 key 列表过大。
     */
    private long deleteDirectDocKeysInBatches(List<String> keysToDelete) {
        if (keysToDelete == null || keysToDelete.isEmpty()) {
            return 0L;
        }
        long deleted = 0L;
        int total = keysToDelete.size();
        for (int from = 0; from < total; from += DIRECT_DOC_DELETE_BATCH_SIZE) {
            int to = Math.min(from + DIRECT_DOC_DELETE_BATCH_SIZE, total);
            deleted += redisCache.deleteObject(keysToDelete.subList(from, to));
        }
        return deleted;
    }

    /**
     * 免授权文档缓存：销售 + 课节 + 账号 + 当天 [+ 分片]。
     * 一批客户复用同一飞书链接（contentType=17），与 shortLink / userId 无关。
     */
    private String buildDirectDocCacheKey(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId,
                                           Integer directDocShard) {
        Long courseIdVal = courseId != null ? courseId : 0L;
        String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
        if (directDocShard == null) {
            return String.format("%s%d:%d:%d:%d:%s",
                    FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey);
        }
        return String.format("%s%d:%d:%d:%d:%s:%d",
                FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey,
                directDocShard);
    }

    private int getDirectDocCacheTtlSeconds() {
        LocalDateTime expireAt = LocalDate.now(ZoneId.systemDefault()).plusDays(1).atTime(LocalTime.of(23, 59, 59));
        long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(ZoneId.systemDefault()), expireAt);
        seconds = Math.max(seconds, 3600L);
        return (int) Math.min(seconds, Integer.MAX_VALUE);
    }

    private String createDirectCourseDocumentWithRetry(FeishuClientHolder holder, String title, String iframeUrl)
            throws Exception {
        Exception lastError = null;
        for (int attempt = 0; attempt < DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES; attempt++) {
            try {
                String documentId = docApiService.createDocument(holder, title);
                docApiService.createIframeBlock(holder, documentId, iframeUrl);
                docApiService.changeDocumentPermissions(holder, documentId);
                return documentId;
            } catch (CustomException e) {
                lastError = e;
                if (isFolderLockedError(e) && attempt < DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES - 1) {
                    log.warn("飞书创建文档 folder locked，第{}次重试: accountId={}",
                            attempt + 1, holder.getAccountId());
                    Thread.sleep(DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS[attempt]);
                    continue;
                }
                throw e;
            } catch (Exception e) {
                lastError = e;
                if (isFolderLockedError(e) && attempt < DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES - 1) {
                    log.warn("飞书创建文档 folder locked，第{}次重试: accountId={}",
                            attempt + 1, holder.getAccountId());
                    Thread.sleep(DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS[attempt]);
                    continue;
                }
                throw e;
            }
        }
        if (lastError instanceof CustomException) {
            throw (CustomException) lastError;
        }
        throw lastError;
    }

    private boolean isFolderLockedError(Throwable error) {
        if (error == null) {
            return false;
        }
        String message = error.getMessage();
        if (message != null && message.toLowerCase().contains("folder locked")) {
            return true;
        }
        return isFolderLockedError(error.getCause());
    }

    private void updateCourseLinkFeishuAccount(String shortLink, Long feishuAccountId) {
        FsCourseLink link = courseLinkMapper.selectFsCourseLinkByLink(shortLink);
        if (link == null) {
            return;
        }
        link.setFeishuAccountId(feishuAccountId);
        courseLinkMapper.updateFsCourseLink(link);
    }

    private boolean isAccountUnavailable(CustomException e) {
        String message = e.getMessage();
        return message != null && (message.contains("不可用") || message.contains("已被自动禁用") || message.contains("已禁用"));
    }

    private String buildCoursePageLink(Long companyId, Long companyUserId, Long courseId,
                                       Long videoId, Long userId, String link, Integer questionFlag) {
        CourseConfig config = getCourseConfig();
        Map<String, Object> courseParam = buildCourseParam(companyId, companyUserId, courseId, videoId, link, questionFlag);

        try {
            String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(courseParam), "UTF-8");
            String courseToken = SecureTokenUtil.generateCourseAccessToken(companyUserId, videoId, userId);
            String encodedToken = URLEncoder.encode(courseToken, "UTF-8");
            String pageUrl = config.getFeishuCourseDomain()
                    + "/feishuCourse/pages_course/video?course=" + encodedJson
                    + "&userId=" + userId
                    + "&token=" + encodedToken;
            return URLEncoder.encode(pageUrl, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw new CustomException("看课URL拼接失败", e);
        }
    }

    private Map<String, Object> buildCourseParam(Long companyId, Long companyUserId, Long courseId,
                                                 Long videoId, String link, Integer questionFlag) {
        Map<String, Object> courseParam = new LinkedHashMap<>();
        courseParam.put("companyUserId", companyUserId);
        courseParam.put("videoId", videoId);
        courseParam.put("companyId", companyId);
        courseParam.put("courseId", courseId);
        courseParam.put("link", link);
        if (questionFlag != null) {
            courseParam.put("questionFlag", questionFlag);
        }
        appendLinkMeta(courseParam, requireCourseLink(link));
        return courseParam;
    }

    private int resolveQuestionFlag(FsUserCourseVideo video) {
        String questionBankId = video.getQuestionBankId();
        boolean hasQuestion = StringUtils.isNotBlank(questionBankId) && !"null".equals(questionBankId);
        return hasQuestion ? 1 : 0;
    }

    private String buildAuthLink(Long companyId, Long companyUserId, Long courseId,
                                 Long videoId, String shortLink) {
        CourseConfig config = getCourseConfig();

        Map<String, Object> authParam = buildCourseParam(companyId, companyUserId, courseId, videoId, shortLink, null);
        authParam.put("feishuNeedAuth", 1);

        try {
            String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(authParam), "UTF-8");
            return URLEncoder.encode(config.getFeishuAuthDomain()
                    + "/feishuCourse/pages/wxauth?course=" + encodedJson, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw new CustomException("授权URL拼接失败", e);
        }
    }

    private FsCourseLink requireCourseLink(String link) {
        FsCourseLink fsCourseLink = courseLinkMapper.selectFsCourseLinkByLink(link);
        if (fsCourseLink == null) {
            throw new CustomException("课程链接已过期");
        }
        if (isCourseLinkExpired(fsCourseLink)) {
            throw new CustomException("链接已过期");
        }
        return fsCourseLink;
    }

    private void appendLinkMeta(Map<String, Object> courseParam, FsCourseLink fsCourseLink) {
        courseParam.put("corpId", fsCourseLink.getCorpId());
        courseParam.put("linkType", fsCourseLink.getLinkType());
        courseParam.put("qwExternalId", fsCourseLink.getQwExternalId());
        courseParam.put("qwUserId", fsCourseLink.getQwUserId());
        courseParam.put("feishuNeedAuth", toStoredFeishuNeedAuth(fsCourseLink.getFeishuNeedAuth()));
        if (StringUtils.isNotBlank(fsCourseLink.getProjectCode())) {
            courseParam.put("projectCode", fsCourseLink.getProjectCode());
        }
    }

    private CourseConfig getCourseConfig() {
        String json = configService.selectConfigByKey("course.config");
        if (StringUtils.isBlank(json)) {
            throw new CustomException("飞书看课配置为空");
        }
        return JSONUtil.toBean(json, CourseConfig.class);
    }
}
