package com.malk.guangming.service;

import com.alibaba.fastjson.JSON;
import com.malk.guangming.config.GuangmingConfig;
import com.malk.guangming.dto.AppPreviewDto;
import com.malk.guangming.dto.AppSyncResultDto;
import com.malk.guangming.repository.dao.DingTalkAppMappingDao;
import com.malk.guangming.repository.entity.primary.DingTalkAppMappingPo;
import com.malk.server.common.McException;
import com.malk.server.dingtalk.DDConf;
import com.malk.service.dingtalk.DDClient;
import com.malk.service.dingtalk.DDClient_Contacts;
import com.malk.service.dingtalk.DDClient_Role;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.Collectors;

@Slf4j
@Service
public class AppRoleSyncService {

    private static final String ROLE_GROUP_NAME = "开放平台应用权限";

    /** 部门展开并行度:大部门可见范围同步展开时的并发钉钉调用数,避免顺序展开超 HTTP 超时 */
    private static final int EXPAND_CONCURRENCY = 10;

    @Autowired
    private GuangmingConfig guangmingConfig;

    @Autowired
    private DDClient ddClient;

    @Autowired
    private DDClient_Contacts ddClient_contacts;

    @Autowired
    private DDClient_Role ddClient_role;

    @Autowired
    private DingTalkAppMappingDao mappingDao;

    /** 自注入代理:@Async 自调用不生效,异步展开需经此代理触发 */
    @Lazy
    @Autowired
    private AppRoleSyncService self;

    /**
     * 单应用同步:读取可见范围 → 部门展开 → 落库;全表首次同步时创建角色组
     */
    public AppSyncResultDto syncOne(String appKey) {
        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));

        String token = getAccessToken();
        VisibleScopeResolved scope;
        try {
            scope = resolveScope(token, mapping.getAgentId());
        } catch (Exception e) {
            log.error("[role-sync] resolveScope 失败, appKey={}", appKey, e);
            mapping.setSyncStatus("failed");
            mapping.setLastErrMsg(truncate("resolve scope failed: " + e.getMessage(), 500));
            mapping.setLastSyncAt(new Date());
            mappingDao.save(mapping);
            throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
        }

        // 1. 先把 raw + expanded 落库(visible_scopes 已成功)
        mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
        mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
        mapping.setIsAllVisible(scope.isAllVisible);
        mapping.setIsHidden(scope.isHidden);
        if (scope.isAllVisible) {
            mapping.setExpandedUsers(null);
            mapping.setExpandedCount(null);
        } else if (scope.expandOk) {
            mapping.setExpandedUsers(JSON.toJSONString(scope.expanded));
            mapping.setExpandedCount(scope.expanded.size());
        }
        mapping.setLastSyncAt(new Date());

        // 2. 尝试创建/拿角色组(失败不丢 raw)
        String roleGroupId = null;
        String roleGroupErr = null;
        try {
            ensureRoleGroup(token);
            roleGroupId = mappingDao.findFirstByRoleGroupIdIsNotNull()
                    .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null);
            mapping.setRoleGroupId(roleGroupId);
        } catch (Exception e) {
            log.warn("[role-sync] 角色组创建失败(可能权限未开通 qyapi_manage_addresslist): {}", e.getMessage());
            roleGroupErr = e.getMessage();
        }

        // 3. 统一 sync_status
        String status;
        StringBuilder err = new StringBuilder();
        if (scope.expandErr != null) err.append("expand:").append(scope.expandErr).append(";");
        if (roleGroupErr != null) err.append("role_group:").append(roleGroupErr).append(";");
        if (scope.expandErr == null && roleGroupErr == null) {
            status = "success";
        } else if (roleGroupErr != null && scope.expandErr != null) {
            status = "partial_no_expand_no_role_group";
        } else if (roleGroupErr != null) {
            status = "partial_no_role_group";
        } else {
            status = "partial_no_expand";
        }
        mapping.setSyncStatus(status);
        mapping.setLastErrMsg(err.length() == 0 ? null : truncate(err.toString(), 500));
        mappingDao.save(mapping);

        return AppSyncResultDto.builder()
                .appName(mapping.getAppName())
                .roleGroupId(roleGroupId)
                .isAllVisible(scope.isAllVisible)
                .expandedCount(mapping.getExpandedCount())
                .syncStatus(status)
                .build();
    }

    /**
     * 异步初始化(大部门可见范围优化):只读可见范围(快)+ 建角色组,状态置 expanding 后立即返回;
     * 部门展开放后台 @Async 执行,完成转 success。避免大部门(差旅类数千部门)同步展开阻塞 HTTP 超时。
     * 调用方:轮询 GET /status 至 success 后再 /users-sync 绑成员。
     */
    public AppSyncResultDto syncOneAsync(String appKey) {
        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
        String token = getAccessToken();
        VisibleScopeResolved scope;
        try {
            scope = readScope(token, mapping.getAgentId());
        } catch (Exception e) {
            log.error("[role-sync] readScope 失败, appKey={}", appKey, e);
            mapping.setSyncStatus("failed");
            mapping.setLastErrMsg(truncate("read scope failed: " + e.getMessage(), 500));
            mapping.setLastSyncAt(new Date());
            mappingDao.save(mapping);
            throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
        }
        mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
        mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
        mapping.setIsAllVisible(scope.isAllVisible);
        mapping.setIsHidden(scope.isHidden);
        mapping.setLastSyncAt(new Date());

        String roleGroupErr = null;
        try {
            ensureRoleGroup(token);
            mapping.setRoleGroupId(mappingDao.findFirstByRoleGroupIdIsNotNull()
                    .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null));
        } catch (Exception e) {
            log.warn("[role-sync] 角色组创建失败: {}", e.getMessage());
            roleGroupErr = e.getMessage();
        }

        String status;
        if (scope.isAllVisible) {
            mapping.setExpandedUsers(null);
            mapping.setExpandedCount(null);
            status = roleGroupErr == null ? "success" : "partial_no_role_group";
        } else if (roleGroupErr != null) {
            status = "partial_no_role_group";
        } else {
            status = "expanding";
        }
        mapping.setSyncStatus(status);
        mapping.setLastErrMsg(roleGroupErr == null ? null : truncate("role_group:" + roleGroupErr, 500));
        mappingDao.save(mapping);

        if ("expanding".equals(status)) {
            self.expandAsync(appKey);   // @Async 经自注入代理触发,立即返回,后台展开
        }
        return AppSyncResultDto.builder()
                .appName(mapping.getAppName())
                .roleGroupId(mapping.getRoleGroupId())
                .isAllVisible(scope.isAllVisible)
                .syncStatus(status)
                .build();
    }

    /**
     * 后台异步展开部门成员:基于 mapping 已落库的 raw_dept/user 并行展开,完成后落 expanded + 状态。
     * 完成转 success;展开失败转 partial_no_expand(保留 raw)。重载 mapping 避免跨线程共享实体。
     */
    @Async
    public void expandAsync(String appKey) {
        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey).orElse(null);
        if (mapping == null) return;
        String token = getAccessToken();
        try {
            List<Long> depts = mapping.getRawDeptIds() == null ? Collections.emptyList()
                    : JSON.parseArray(mapping.getRawDeptIds(), Long.class);
            List<String> users = mapping.getRawUserIds() == null ? Collections.emptyList()
                    : JSON.parseArray(mapping.getRawUserIds(), String.class);
            Set<String> expanded = expandDeptUsersParallel(token, depts, users);
            mapping.setExpandedUsers(JSON.toJSONString(expanded));
            mapping.setExpandedCount(expanded.size());
            mapping.setSyncStatus("success");
            mapping.setLastErrMsg(null);
            log.info("[role-sync] expandAsync 完成 appKey={} expanded={}", appKey, expanded.size());
        } catch (Exception e) {
            log.error("[role-sync] expandAsync 失败 appKey={}", appKey, e);
            mapping.setSyncStatus("partial_no_expand");
            mapping.setLastErrMsg(truncate("expand:" + e.getMessage(), 500));
        }
        mapping.setLastSyncAt(new Date());
        mappingDao.save(mapping);
    }

    /**
     * dryRun:不落库
     */
    public AppPreviewDto previewOne(String appKey) {
        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));

        String token = getAccessToken();
        VisibleScopeResolved scope = resolveScope(token, mapping.getAgentId());

        return AppPreviewDto.builder()
                .appName(mapping.getAppName())
                .rawDeptIds(scope.deptIds)
                .rawUserIds(scope.userIds)
                .isAllVisible(scope.isAllVisible)
                .isHidden(scope.isHidden)
                .expanded(scope.isAllVisible ? null : new ArrayList<>(scope.expanded))
                .build();
    }

    public DingTalkAppMappingPo getStatus(String appKey) {
        return mappingDao.findByAppKey(appKey)
                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));
    }

    /**
     * Phase 2:对单个应用绑定钉钉角色 + 全量推送人员。
     * 前置:必须先跑过 syncOne(库内有 expanded_users 与 role_group_id)。
     * 行为:
     * - is_all_visible=1 跳过角色绑定(应用可见范围就是全员,无需 role)
     * - role_id 为空时 roleCreate 拿到 roleId 回写
     * - 全量 roleAddUsers(本期不做 diff 删除;V2 再加)
     */
    public AppSyncResultDto syncUsersForApp(String appKey) {
        DingTalkAppMappingPo mapping = mappingDao.findByAppKey(appKey)
                .orElseThrow(() -> new McException("404", "appKey not registered: " + appKey));

        if (mapping.getRoleGroupId() == null) {
            throw new McException("400", "role_group_id is null, run /single first: " + appKey);
        }
        if ("expanding".equals(mapping.getSyncStatus())) {
            throw new McException("409", "部门展开进行中(异步),请待 /status 为 success 后再绑成员: " + appKey);
        }

        String token = getAccessToken();
        try {
            // 分支 A:全员可见 → 不建角色,改调钉钉 set_visible_scopes 把应用可见范围 addDeptIds=[1]
            if (Boolean.TRUE.equals(mapping.getIsAllVisible())) {
                ddClient_role.setMicroAppVisibleScopes(
                        token, Long.parseLong(mapping.getAgentId()),
                        java.util.Arrays.asList(1L), null, null, null, null, null);
                mapping.setRoleLabel("全员(无角色)");
                mapping.setSyncStatus("success_all_visible");
                mapping.setLastErrMsg(null);
                mapping.setLastSyncAt(new Date());
                mappingDao.save(mapping);
                return AppSyncResultDto.builder()
                        .appName(mapping.getAppName())
                        .roleGroupId(mapping.getRoleGroupId())
                        .isAllVisible(Boolean.TRUE)
                        .syncStatus("success_all_visible").build();
            }

            // 分支 B:非全员 → 建角色 + 加人,角色名 = "分组 - 应用名"
            String roleName = buildRoleName(mapping);
            if (mapping.getRoleId() == null) {
                String roleId = ddClient_role.roleCreate(token, roleName, mapping.getRoleGroupId());
                if (roleId == null) {
                    throw new McException("502", "roleCreate returned null roleId");
                }
                mapping.setRoleId(roleId);
            } else if (!roleName.equals(mapping.getRoleLabel())) {
                // 已建但名称需要按新规则更新(如 DHR → "集团应用 - DHR")
                ddClient_role.roleUpdate(token, mapping.getRoleId(), roleName);
            }

            List<String> users = JSON.parseArray(
                    mapping.getExpandedUsers() == null ? "[]" : mapping.getExpandedUsers(),
                    String.class);
            ddClient_role.roleAddUsers(token, mapping.getRoleId(), users);

            mapping.setRoleLabel(roleName);
            mapping.setSyncStatus("success");
            mapping.setLastErrMsg(null);
            mapping.setLastSyncAt(new Date());
            mappingDao.save(mapping);

            return AppSyncResultDto.builder()
                    .appName(mapping.getAppName())
                    .roleGroupId(mapping.getRoleGroupId())
                    .isAllVisible(Boolean.FALSE)
                    .expandedCount(users.size())
                    .syncStatus("success").build();
        } catch (Exception e) {
            log.error("[role-sync] syncUsersForApp 失败, appKey={}", appKey, e);
            mapping.setSyncStatus("failed");
            mapping.setLastErrMsg(truncate(e.getMessage(), 500));
            mapping.setLastSyncAt(new Date());
            mappingDao.save(mapping);
            throw e instanceof McException ? (McException) e : new McException("502", "dingtalk vendor error: " + e.getMessage());
        }
    }

    private static String buildRoleName(DingTalkAppMappingPo mapping) {
        String group = mapping.getAppGroup();
        if (group == null || group.isEmpty()) return mapping.getAppName();
        return group + " - " + mapping.getAppName();
    }

    /**
     * 从钉钉拉取自建应用列表,新增到 t_dingtalk_app_mapping;
     * 已存在 appKey 跳过。返回新增数。
     */
    public Map importApps() {
        String token = getAccessToken();
        List<Map> apps = ddClient_role.listMicroApp(token);
        int inserted = 0, skipped = 0;
        // 钉钉 /microapp/list 不返回 appKey,以 agentId 作为唯一标识;app_key 字段写占位 "ag-{agentId}"
        for (Map app : apps) {
            Object agentIdObj = app.get("agentId");
            String name = String.valueOf(app.get("name"));
            if (agentIdObj == null) continue;
            String agentId = String.valueOf(agentIdObj);
            String appKeyPlaceholder = "ag-" + agentId;
            if (mappingDao.findByAppKey(appKeyPlaceholder).isPresent() ||
                mappingDao.findByAppKey(agentId).isPresent()) {
                skipped++;
                continue;
            }
            // 也排查真实 appKey 已存在的应用(如 DHR 手工录入)
            boolean existsByAgent = mappingDao.findAll().stream()
                    .anyMatch(p -> agentId.equals(p.getAgentId()));
            if (existsByAgent) {
                skipped++;
                continue;
            }
            DingTalkAppMappingPo po = new DingTalkAppMappingPo();
            po.setAppName(name);
            po.setAgentId(agentId);
            po.setAppKey(appKeyPlaceholder);
            po.setSyncStatus("pending");
            mappingDao.save(po);
            inserted++;
        }
        Map ret = new java.util.HashMap();
        ret.put("totalFromDingtalk", apps.size());
        ret.put("inserted", inserted);
        ret.put("skipped", skipped);
        return ret;
    }

    /**
     * 全量批量:对映射表里**所有**应用按顺序跑 syncOne → syncUsersForApp。
     * 单条失败不中断其它,落 sync_status 即可。
     */
    /**
     * 一站式初始化:落库 + 建角色 + 绑人员 + 反写应用可见范围
     * 业务方主要入口,幂等(重复跑同 agentId,会重读可见范围并补齐数据)
     */
    public AppSyncResultDto initApp(String agentId, String appName, String appGroup) {
        String token = getAccessToken();
        // 0. 幂等守卫:已成功初始化的应用直接返回当前状态(可见范围已反写为角色或根部门,再次读 visible_scopes 会丢原始数据)
        DingTalkAppMappingPo existing = mappingDao.findFirstByAgentId(agentId).orElse(null);
        // fixme: skip_shortcut = OA办公组快捷应用(工作台快捷方式配置地址,默认全员可见),不动钉钉应用本身可见范围;
        //        必须纳入幂等守卫,否则重跑会走全员分支误调 writeBackToRootDept 改可见范围
        if (existing != null && existing.getLastSyncAt() != null &&
            ("success".equals(existing.getSyncStatus()) || "success_all_visible".equals(existing.getSyncStatus())
                    || "skip_shortcut".equals(existing.getSyncStatus()))) {
            log.info("[role-sync] initApp 幂等返回 agentId={}, 已 {} 于 {}", agentId, existing.getSyncStatus(), existing.getLastSyncAt());
            return AppSyncResultDto.builder()
                    .appName(existing.getAppName())
                    .roleGroupId(existing.getRoleGroupId())
                    .isAllVisible(Boolean.TRUE.equals(existing.getIsAllVisible()))
                    .expandedCount(existing.getExpandedCount())
                    .syncStatus(existing.getSyncStatus()).build();
        }

        // 1. 查或建 mapping
        DingTalkAppMappingPo mapping = existing;
        if (mapping == null) {
            String resolvedName = (appName != null && !appName.isEmpty()) ? appName : lookupAppNameFromDingtalk(token, agentId);
            mapping = new DingTalkAppMappingPo();
            mapping.setAppName(resolvedName);
            mapping.setAppGroup(appGroup);
            mapping.setAgentId(agentId);
            mapping.setAppKey("ag-" + agentId);
            mapping.setSyncStatus("pending");
            mapping = mappingDao.save(mapping);
        } else {
            if (appName != null && !appName.isEmpty()) mapping.setAppName(appName);
            if (appGroup != null) mapping.setAppGroup(appGroup);
        }

        // 2. 读可见范围 + 展开
        VisibleScopeResolved scope = resolveScope(token, mapping.getAgentId());

        // 3. 落 raw
        mapping.setRawDeptIds(JSON.toJSONString(scope.deptIds));
        mapping.setRawUserIds(JSON.toJSONString(scope.userIds));
        mapping.setIsAllVisible(scope.isAllVisible);
        mapping.setIsHidden(scope.isHidden);

        // 4. 全局角色组
        ensureRoleGroup(token);
        String roleGroupId = mappingDao.findFirstByRoleGroupIdIsNotNull()
                .map(DingTalkAppMappingPo::getRoleGroupId).orElse(null);
        mapping.setRoleGroupId(roleGroupId);

        long agentIdNum = Long.parseLong(mapping.getAgentId());
        String status;
        if (scope.isAllVisible) {
            // 全员路径:反写应用可见范围 addDept=1 + 清非根 + 清 user
            mapping.setExpandedUsers(null);
            mapping.setExpandedCount(null);
            mapping.setRoleLabel("全员(无角色)");
            writeBackToRootDept(token, agentIdNum, scope.deptIds, scope.userIds);
            status = "success_all_visible";
        } else {
            if (!scope.expandOk) {
                throw new McException("502", "部门展开失败: " + scope.expandErr);
            }
            mapping.setExpandedUsers(JSON.toJSONString(scope.expanded));
            mapping.setExpandedCount(scope.expanded.size());
            String roleName = buildRoleName(mapping);
            if (mapping.getRoleId() == null) {
                String roleId = ddClient_role.roleCreate(token, roleName, roleGroupId);
                if (roleId == null) throw new McException("502", "roleCreate returned null");
                mapping.setRoleId(roleId);
            }
            mapping.setRoleLabel(roleName);
            // 全量加人
            ddClient_role.roleAddUsers(token, mapping.getRoleId(), scope.expanded);
            // 反写:addRole + 清 dept/user
            writeBackToRole(token, agentIdNum, mapping.getRoleId(), scope.deptIds, scope.userIds);
            status = "success";
        }
        mapping.setSyncStatus(status);
        mapping.setLastErrMsg(null);
        mapping.setLastSyncAt(new Date());
        mappingDao.save(mapping);

        return AppSyncResultDto.builder()
                .appName(mapping.getAppName())
                .roleGroupId(roleGroupId)
                .isAllVisible(scope.isAllVisible)
                .expandedCount(mapping.getExpandedCount())
                .syncStatus(status).build();
    }

    /**
     * 增量更新角色成员(不动应用可见范围、不读可见范围)
     */
    public Map updateUsers(String agentId, List<String> addUserIds, List<String> delUserIds) {
        DingTalkAppMappingPo mapping = mappingDao.findFirstByAgentId(agentId)
                .orElseThrow(() -> new McException("404", "agentId not registered: " + agentId));
        if (mapping.getRoleId() == null) {
            throw new McException("400", "该应用未建角色(可能为全员可见),无法增量更新角色成员");
        }
        String token = getAccessToken();
        int addedCount = 0, deletedCount = 0;
        if (addUserIds != null && !addUserIds.isEmpty()) {
            ddClient_role.roleAddUsers(token, mapping.getRoleId(), addUserIds);
            addedCount = addUserIds.size();
        }
        if (delUserIds != null && !delUserIds.isEmpty()) {
            ddClient_role.roleDeleteUsers(token, mapping.getRoleId(), delUserIds);
            deletedCount = delUserIds.size();
        }
        // 更新 mapping.expanded_users
        List<String> current = mapping.getExpandedUsers() == null
                ? new ArrayList<>()
                : JSON.parseArray(mapping.getExpandedUsers(), String.class);
        LinkedHashSet<String> set = new LinkedHashSet<>(current);
        if (addUserIds != null) set.addAll(addUserIds);
        if (delUserIds != null) set.removeAll(delUserIds);
        mapping.setExpandedUsers(JSON.toJSONString(set));
        mapping.setExpandedCount(set.size());
        mapping.setLastSyncAt(new Date());
        mappingDao.save(mapping);

        Map ret = new java.util.HashMap();
        ret.put("agentId", agentId);
        ret.put("roleId", mapping.getRoleId());
        ret.put("addedCount", addedCount);
        ret.put("deletedCount", deletedCount);
        ret.put("totalCount", set.size());
        return ret;
    }

    /**
     * 第二步(独立):仅把应用可见范围写回为已建角色(addRoleIds + 清原 dept/user)。
     * 不读可见范围、不展开、不建角色;要求该应用已建角色(role_id 非空)。
     * 用 mapping 落库的 raw_dept_ids/raw_user_ids 作为待清的原可见范围。
     */
    public Map writeBackOne(String agentId) {
        DingTalkAppMappingPo mapping = mappingDao.findFirstByAgentId(agentId)
                .orElseThrow(() -> new McException("404", "agentId not registered: " + agentId));
        if (mapping.getRoleId() == null) {
            throw new McException("400", "该应用未建角色(可能为全员可见),无法写回可见范围为角色: " + agentId);
        }
        String token = getAccessToken();
        List<Long> curDepts = mapping.getRawDeptIds() == null ? Collections.emptyList()
                : JSON.parseArray(mapping.getRawDeptIds(), Long.class);
        List<String> curUsers = mapping.getRawUserIds() == null ? Collections.emptyList()
                : JSON.parseArray(mapping.getRawUserIds(), String.class);
        writeBackToRole(token, Long.parseLong(mapping.getAgentId()), mapping.getRoleId(), curDepts, curUsers);
        mapping.setLastSyncAt(new Date());
        mappingDao.save(mapping);

        Map ret = new java.util.HashMap();
        ret.put("agentId", agentId);
        ret.put("roleId", mapping.getRoleId());
        ret.put("roleLabel", mapping.getRoleLabel());
        ret.put("clearedDepts", curDepts.size());
        ret.put("clearedUsers", curUsers.size());
        return ret;
    }

    /** 反写为根部门:addDeptIds=[1] + 删非根 dept + 删全部 user(user 分 50/批) */
    private void writeBackToRootDept(String token, long agentId, List<Long> curDepts, List<String> curUsers) {
        List<Long> delDepts = curDepts == null ? Collections.emptyList()
                : curDepts.stream().filter(d -> d != 1L).collect(Collectors.toList());
        ddClient_role.setMicroAppVisibleScopes(token, agentId,
                java.util.Arrays.asList(1L), null, null,
                delDepts.isEmpty() ? null : delDepts, null, null);
        delUsersBatched(token, agentId, curUsers);
    }

    /** 反写为角色:addRoleIds=[roleId] + 删 dept + 删 user(user 分 50/批) */
    private void writeBackToRole(String token, long agentId, String roleId, List<Long> curDepts, List<String> curUsers) {
        ddClient_role.setMicroAppVisibleScopes(token, agentId,
                null, null, java.util.Arrays.asList(roleId),
                curDepts == null || curDepts.isEmpty() ? null : curDepts, null, null);
        delUsersBatched(token, agentId, curUsers);
    }

    /** 钉钉 set_visible_scopes 单次 delUserIds 上限约 50,分批 */
    private void delUsersBatched(String token, long agentId, List<String> users) {
        if (users == null || users.isEmpty()) return;
        for (int i = 0; i < users.size(); i += 50) {
            List<String> batch = users.subList(i, Math.min(i + 50, users.size()));
            ddClient_role.setMicroAppVisibleScopes(token, agentId,
                    null, null, null, null, batch, null);
        }
    }

    @SuppressWarnings("unchecked")
    private String lookupAppNameFromDingtalk(String token, String agentId) {
        long aid = Long.parseLong(agentId);
        List<Map> apps = ddClient_role.listMicroApp(token);
        return apps.stream()
                .filter(a -> a.get("agentId") != null && Long.parseLong(String.valueOf(a.get("agentId"))) == aid)
                .map(a -> String.valueOf(a.get("name")))
                .findFirst()
                .orElseThrow(() -> new McException("404", "agentId 在钉钉应用列表中未找到: " + agentId));
    }

    public List<AppSyncResultDto> syncAll() {
        List<DingTalkAppMappingPo> all = mappingDao.findAll();
        List<AppSyncResultDto> results = new ArrayList<>(all.size());
        for (DingTalkAppMappingPo po : all) {
            try {
                syncOne(po.getAppKey());
                results.add(syncUsersForApp(po.getAppKey()));
            } catch (Exception e) {
                log.error("[role-sync] syncAll 单条失败, appKey={}", po.getAppKey(), e);
                results.add(AppSyncResultDto.builder()
                        .appName(po.getAppName())
                        .syncStatus("failed")
                        .build());
            }
        }
        return results;
    }

    /// 内部工具 ///

    private String getAccessToken() {
        return ddClient.getAccessToken(
                guangmingConfig.getDingtalk().getAppKey(),
                guangmingConfig.getDingtalk().getAppSecret());
    }

    private VisibleScopeResolved resolveScope(String token, String agentIdStr) {
        VisibleScopeResolved scope = readScope(token, agentIdStr);
        if (!scope.isAllVisible) {
            try {
                scope.expanded = expandDeptUsersParallel(token, scope.deptIds, scope.userIds);
                scope.expandOk = true;
            } catch (Exception e) {
                log.warn("[role-sync] 部门展开失败(可能权限未开通 qyapi_get_department_list),降级 raw-only: {}", e.getMessage());
                scope.expandErr = e.getMessage();
                scope.expandOk = false;
            }
        } else {
            scope.expandOk = true;
        }
        return scope;
    }

    /** 只读可见范围(不展开):1 次 visible_scopes 调用,快;供异步展开路径先拿 raw 再后台展开 */
    @SuppressWarnings("unchecked")
    private VisibleScopeResolved readScope(String token, String agentIdStr) {
        long agentId = Long.parseLong(agentIdStr);
        Map rsp = ddClient_role.getMicroAppVisibleScopes(token, agentId);

        List<Number> rawDept = rsp.get("deptVisibleScopes") instanceof List ? (List<Number>) rsp.get("deptVisibleScopes") : Collections.emptyList();
        List<String> rawUser = rsp.get("userVisibleScopes") instanceof List ? (List<String>) rsp.get("userVisibleScopes") : Collections.emptyList();
        Boolean isHidden = rsp.get("isHidden") instanceof Boolean ? (Boolean) rsp.get("isHidden") : Boolean.FALSE;

        List<Long> deptIds = new ArrayList<>();
        for (Number n : rawDept) deptIds.add(n.longValue());
        List<String> userIds = new ArrayList<>(rawUser);

        VisibleScopeResolved scope = new VisibleScopeResolved();
        scope.deptIds = deptIds;
        scope.userIds = userIds;
        scope.isHidden = isHidden;
        scope.isAllVisible = deptIds.contains(DDConf.TOP_DEPARTMENT);
        return scope;
    }

    /**
     * 并行展开部门成员:并行 BFS 收集子部门 + 并行取各部门成员。
     * 替代 base 顺序递归 getDepartmentId_all + 逐部门 listDepartmentUserId,
     * 降低大部门可见范围在同步 HTTP 内展开的耗时(差旅类 400+ 部门曾顺序 800+ 调用超时)。
     */
    private Set<String> expandDeptUsersParallel(String token, List<Long> deptIds, List<String> seedUserIds) throws Exception {
        // 1. 并行 BFS 逐层收集子部门;visited(allDept)去重,避免重叠/嵌套部门重复展开
        Set<Long> allDept = ConcurrentHashMap.newKeySet();
        allDept.addAll(deptIds);
        List<Long> frontier = new ArrayList<>(deptIds);
        ForkJoinPool pool = new ForkJoinPool(EXPAND_CONCURRENCY);
        try {
            while (!frontier.isEmpty()) {
                final List<Long> level = frontier;
                List<Long> children = pool.submit(() ->
                        level.parallelStream()
                                .flatMap(d -> ddClient_contacts.listSubDepartmentId(token, d).stream())
                                .collect(Collectors.toList())
                ).get();
                List<Long> next = new ArrayList<>();
                for (Long c : children) {
                    // fixme: allDept.add 返回 false = 已访问,跳过,防重叠部门导致的重复 BFS
                    if (allDept.add(c)) next.add(c);
                }
                frontier = next;
            }
            // 2. 并行取各部门成员(seed = 可见范围里直接指定的 userIds)
            List<Long> deptList = new ArrayList<>(allDept);
            Set<String> deptUsers = pool.submit(() ->
                    deptList.parallelStream()
                            .flatMap(d -> ddClient_contacts.listDepartmentUserId(token, d).stream())
                            .collect(Collectors.toSet())
            ).get();
            Set<String> users = new LinkedHashSet<>(seedUserIds);
            users.addAll(deptUsers);
            return users;
        } finally {
            pool.shutdown();
        }
    }

    /**
     * 全局角色组只创建一次。
     * synchronized 保护并发同步同一个 appKey 时不重复创建。
     */
    private synchronized void ensureRoleGroup(String token) {
        if (mappingDao.findFirstByRoleGroupIdIsNotNull().isPresent()) {
            return;
        }
        String groupId = ddClient_role.roleGroupCreate(token, ROLE_GROUP_NAME);
        if (groupId == null) {
            throw new McException("502", "roleGroupCreate returned null groupId");
        }
        int updated = mappingDao.updateAllRoleGroupId(groupId);
        log.info("[role-sync] 角色组创建成功 groupId={}, 回填行数={}", groupId, updated);
    }

    private static String truncate(String s, int max) {
        if (s == null) return null;
        return s.length() <= max ? s : s.substring(0, max);
    }

    /** 内部值对象 */
    private static class VisibleScopeResolved {
        List<Long> deptIds;
        List<String> userIds;
        Boolean isHidden;
        boolean isAllVisible;
        Set<String> expanded = Collections.emptySet();
        boolean expandOk;
        String expandErr;
    }
}
