package com.zcloud.personnel.positioning.command;

import com.alibaba.cola.dto.PageResponse;
import com.alibaba.cola.exception.BizException;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jjb.saas.framework.auth.model.SSOUser;
import com.jjb.saas.framework.auth.utils.AuthContext;
import com.zcloud.personnel.positioning.dto.TerminalCardCompanyOptionQry;
import com.zcloud.personnel.positioning.dto.TerminalCardCompanyPageQry;
import com.zcloud.personnel.positioning.dto.TerminalCardPageQry;
import com.zcloud.personnel.positioning.dto.TerminalCardPersonOptionQry;
import com.zcloud.personnel.positioning.dto.TerminalCardSaveCmd;
import com.zcloud.personnel.positioning.dto.TerminalCardSyncCmd;
import com.zcloud.personnel.positioning.dto.TerminalCardUpdateCmd;
import com.zcloud.personnel.positioning.dto.clientobject.TerminalCardCO;
import com.zcloud.personnel.positioning.dto.clientobject.TerminalCardCompanyCO;
import com.zcloud.personnel.positioning.dto.clientobject.TerminalCardCompanyOptionCO;
import com.zcloud.personnel.positioning.dto.clientobject.TerminalCardPersonOptionCO;
import com.zcloud.personnel.positioning.dto.clientobject.TerminalCardSyncCO;
import com.zcloud.personnel.positioning.integration.finds.FindsOpenApiClient;
import com.zcloud.personnel.positioning.persistence.dataobject.TerminalBindDO;
import com.zcloud.personnel.positioning.persistence.dataobject.TerminalDO;
import com.zcloud.personnel.positioning.persistence.repository.TerminalBindRepository;
import com.zcloud.personnel.positioning.persistence.repository.TerminalRepository;
import lombok.AllArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
@AllArgsConstructor
public class TerminalCardExe {
    private static final String DEVICE_TYPE_CARD = "CARD";
    private static final String BIND_STATUS_BOUND = "BOUND";
    private static final String BIND_STATUS_UNBOUND = "UNBOUND";
    private static final String TRACK_TYPE_STAFF = "STAFF";
    private static final String SYNC_STATUS_SUCCESS = "SUCCESS";
    private static final String SYNC_STATUS_WAIT = "WAIT";
    private static final String TERMINAL_LIST_API = "finds.terminal.list";
    private static final int DEFAULT_SYNC_PAGE_SIZE = 200;
    private static final int DEFAULT_SYNC_MAX_PAGE_COUNT = 50;

    private final TerminalRepository terminalRepository;
    private final TerminalBindRepository terminalBindRepository;
    private final FindsOpenApiClient findsOpenApiClient;
    private final ObjectMapper objectMapper;
    private final JdbcTemplate jdbcTemplate;
    private final FindsPendingSyncService findsPendingSyncService;
    private final TransactionTemplate transactionTemplate;

    public PageResponse<TerminalCardCompanyCO> listCompanyPage(TerminalCardCompanyPageQry qry) {
        TerminalCardCompanyPageQry query = qry == null ? new TerminalCardCompanyPageQry() : qry;
        int pageIndex = normalizePageIndex(query.getPageIndex());
        int pageSize = normalizePageSize(query.getPageSize());
        int offset = (pageIndex - 1) * pageSize;

        List<Object> args = new ArrayList<>();
        String fromWhere = buildCompanyFromWhere(query, args);
        String groupKey = "COALESCE(CAST(t.corpinfo_id AS CHAR), NULLIF(t.org_code, ''), "
                + "NULLIF(t.corpinfo_name, ''), NULLIF(t.org_name, ''), 'UNKNOWN')";
        String countSql = "SELECT COUNT(1) FROM (SELECT " + groupKey + " AS company_key "
                + fromWhere + " GROUP BY company_key) x";
        Long total = jdbcTemplate.queryForObject(countSql, args.toArray(), Long.class);

        String dataSql = "SELECT MIN(t.corpinfo_id) AS corpinfo_id, "
                + "COALESCE(MAX(NULLIF(t.corpinfo_name, '')), MAX(NULLIF(t.org_name, '')), "
                + "MAX(NULLIF(ci.corp_name, '')), '未归属单位') AS corpinfo_name, "
                + "MAX(NULLIF(t.org_code, '')) AS org_code, "
                + "MAX(NULLIF(t.org_name, '')) AS org_name, "
                + "COALESCE(MAX(NULLIF(ci.contacts, '')), MAX(NULLIF(ci.lr_name, ''))) AS contact_name, "
                + "COUNT(1) AS card_count, "
                + "SUM(CASE WHEN t.enable = 1 THEN 1 ELSE 0 END) AS enabled_count, "
                + "MAX(t.last_sync_time) AS last_sync_time "
                + fromWhere
                + " GROUP BY " + groupKey
                + " ORDER BY MAX(t.last_sync_time) DESC, MAX(t.update_time) DESC"
                + " LIMIT ? OFFSET ?";
        List<Object> dataArgs = new ArrayList<>(args);
        dataArgs.add(pageSize);
        dataArgs.add(offset);
        List<TerminalCardCompanyCO> rows = jdbcTemplate.query(dataSql, dataArgs.toArray(), this::mapCompany);
        return PageResponse.of(rows, total == null ? 0 : total.intValue(), pageSize, pageIndex);
    }

    public PageResponse<TerminalCardCO> listCardPage(TerminalCardPageQry qry) {
        TerminalCardPageQry query = qry == null ? new TerminalCardPageQry() : qry;
        int pageIndex = normalizePageIndex(query.getPageIndex());
        int pageSize = normalizePageSize(query.getPageSize());
        int offset = (pageIndex - 1) * pageSize;

        List<Object> args = new ArrayList<>();
        String fromWhere = buildCardFromWhere(query, args, null);
        Long total = jdbcTemplate.queryForObject("SELECT COUNT(1) " + fromWhere, args.toArray(), Long.class);
        String dataSql = buildCardSelect() + fromWhere
                + " ORDER BY t.update_time DESC, t.id DESC LIMIT ? OFFSET ?";
        List<Object> dataArgs = new ArrayList<>(args);
        dataArgs.add(pageSize);
        dataArgs.add(offset);
        List<TerminalCardCO> rows = jdbcTemplate.query(dataSql, dataArgs.toArray(), this::mapCard);
        return PageResponse.of(rows, total == null ? 0 : total.intValue(), pageSize, pageIndex);
    }

    public TerminalCardCO queryCardById(Long id) {
        if (id == null) {
            throw new BizException("Terminal card id is required");
        }
        List<Object> args = new ArrayList<>();
        String fromWhere = buildCardFromWhere(new TerminalCardPageQry(), args, id);
        List<TerminalCardCO> rows = jdbcTemplate.query(buildCardSelect() + fromWhere + " LIMIT 1",
                args.toArray(), this::mapCard);
        return rows.isEmpty() ? null : rows.get(0);
    }

    public TerminalCardSyncCO syncCards(TerminalCardSyncCmd cmd) {
        TerminalCardSyncCmd command = cmd == null ? new TerminalCardSyncCmd() : cmd;
        int pageSize = normalizePositive(command.getPageSize(), DEFAULT_SYNC_PAGE_SIZE);
        int maxPageCount = normalizePositive(command.getMaxPageCount(), DEFAULT_SYNC_MAX_PAGE_COUNT);
        TerminalCardSyncCO result = new TerminalCardSyncCO();
        result.setPulledCount(0);
        result.setInsertedCount(0);
        result.setUpdatedCount(0);
        result.setSkippedCount(0);
        result.setBindingCount(0);
        result.setSyncTime(LocalDateTime.now());

        long total = Long.MAX_VALUE;
        int pageNo = 1;
        while (result.getPulledCount() < total && pageNo <= maxPageCount) {
            JsonNode root = findsOpenApiClient.post(TERMINAL_LIST_API, buildTerminalListRequest(command, pageNo, pageSize));
            JsonNode data = assertSuccess(root, TERMINAL_LIST_API).path("data");
            JsonNode rows = extractRows(data);
            if (!rows.isArray() || rows.size() == 0) {
                break;
            }
            total = totalValue(data, rows.size());
            transactionTemplate.execute(status -> {
                for (JsonNode row : rows) {
                    SyncRowResult rowResult = upsertSyncedCard(row, command, result.getSyncTime());
                    if (rowResult.skipped) {
                        result.setSkippedCount(result.getSkippedCount() + 1);
                        continue;
                    }
                    result.setPulledCount(result.getPulledCount() + 1);
                    if (rowResult.inserted) {
                        result.setInsertedCount(result.getInsertedCount() + 1);
                    } else {
                        result.setUpdatedCount(result.getUpdatedCount() + 1);
                    }
                    if (rowResult.bindingSynced) {
                        result.setBindingCount(result.getBindingCount() + 1);
                    }
                }
                return null;
            });
            pageNo++;
        }
        return result;
    }

    @Transactional(rollbackFor = Exception.class)
    public TerminalCardCO saveCard(TerminalCardSaveCmd cmd) {
        validateSave(cmd);
        if (queryTerminalByNo(cmd.getTerminalNo()) != null) {
            throw new BizException("Terminal card no already exists");
        }
        CorpInfo corpInfo = resolveCorpInfo(cmd.getCorpinfoId(), cmd.getCorpinfoName(), cmd.getOrgCode(), cmd.getOrgName());
        TerminalDO terminal = new TerminalDO();
        terminal.setTerminalNo(cmd.getTerminalNo().trim());
        terminal.setTerminalName(firstText(cmd.getTerminalName(), cmd.getTerminalNo()));
        terminal.setTerminalModel(cmd.getTerminalModel());
        terminal.setDeviceType(DEVICE_TYPE_CARD);
        terminal.setSimCardNo(cmd.getSimCardNo());
        terminal.setCheckCode(cmd.getCheckCode());
        terminal.setCorpinfoId(corpInfo == null ? cmd.getCorpinfoId() : corpInfo.corpinfoId);
        terminal.setCorpinfoName(firstText(cmd.getCorpinfoName(), corpInfo == null ? null : corpInfo.corpName, cmd.getOrgName()));
        terminal.setOrgCode(firstText(cmd.getOrgCode(), corpInfo == null ? null : corpInfo.creditCode));
        terminal.setOrgName(firstText(cmd.getOrgName(), terminal.getCorpinfoName()));
        terminal.setEnable(cmd.getEnable() == null ? 1 : cmd.getEnable());
        terminal.setSyncStatus(SYNC_STATUS_WAIT);
        terminal.setSyncToken(FindsSyncToken.next());
        terminal.setRemarks(cmd.getRemarks());
        terminalRepository.save(terminal);

        PersonSnapshot person = resolvePerson(cmd.getBindIdCardNo(), cmd.getStaffNo(), cmd.getStaffName());
        if (hasPerson(person)) {
            upsertActiveBinding(terminal, person, true, LocalDateTime.now());
        }
        findsPendingSyncService.scheduleTerminal(terminal.getId());
        return queryCardById(terminal.getId());
    }

    @Transactional(rollbackFor = Exception.class)
    public TerminalCardCO updateCard(TerminalCardUpdateCmd cmd) {
        if (cmd == null || cmd.getId() == null) {
            throw new BizException("Terminal card id is required");
        }
        TerminalDO terminal = queryTerminalById(cmd.getId());
        if (terminal == null || !DEVICE_TYPE_CARD.equalsIgnoreCase(terminal.getDeviceType())) {
            throw new BizException("Terminal card does not exist");
        }
        String oldTerminalNo = terminal.getTerminalNo();
        if (StringUtils.hasText(cmd.getTerminalNo()) && !cmd.getTerminalNo().trim().equals(oldTerminalNo)) {
            TerminalDO duplicate = queryTerminalByNo(cmd.getTerminalNo().trim());
            if (duplicate != null && !duplicate.getId().equals(terminal.getId())) {
                throw new BizException("Terminal card no already exists");
            }
        }
        CorpInfo corpInfo = resolveCorpInfo(cmd.getCorpinfoId(), cmd.getCorpinfoName(), cmd.getOrgCode(), cmd.getOrgName());
        applyUpdate(cmd, terminal, corpInfo);
        terminal.setSyncStatus(SYNC_STATUS_WAIT);
        terminal.setSyncToken(FindsSyncToken.next());
        terminalRepository.updateById(terminal);
        if (!oldTerminalNo.equals(terminal.getTerminalNo())) {
            moveActiveBindingTerminalNo(oldTerminalNo, terminal);
        }

        if (Boolean.TRUE.equals(cmd.getClearBind())) {
            unbindActiveBinding(terminal, true, LocalDateTime.now());
        } else {
            PersonSnapshot person = resolvePerson(cmd.getBindIdCardNo(), cmd.getStaffNo(), cmd.getStaffName());
            if (hasPerson(person)) {
                upsertActiveBinding(terminal, person, true, LocalDateTime.now());
            }
        }
        findsPendingSyncService.scheduleTerminal(terminal.getId());
        return queryCardById(terminal.getId());
    }

    public PageResponse<TerminalCardPersonOptionCO> listPersonOptions(TerminalCardPersonOptionQry qry) {
        TerminalCardPersonOptionQry query = qry == null ? new TerminalCardPersonOptionQry() : qry;
        int pageIndex = normalizePageIndex(query.getPageIndex());
        int pageSize = normalizePageSize(query.getPageSize());
        int offset = (pageIndex - 1) * pageSize;
        List<Object> args = new ArrayList<>();
        String fromWhere = buildPersonOptionFromWhere(query, args);
        Long total = jdbcTemplate.queryForObject("SELECT COUNT(1) " + fromWhere, args.toArray(), Long.class);
        String dataSql = "SELECT u.id AS user_id, u.username AS staff_no, u.name AS staff_name, "
                + "u.user_id_card AS id_card_no, u.phone AS mobile_no, u.corpinfo_id AS corpinfo_id, "
                + "ci.corp_name AS corpinfo_name, u.department_id AS department_id, u.post_name AS post_name "
                + fromWhere + " ORDER BY u.name ASC, u.id DESC LIMIT ? OFFSET ?";
        List<Object> dataArgs = new ArrayList<>(args);
        dataArgs.add(pageSize);
        dataArgs.add(offset);
        List<TerminalCardPersonOptionCO> rows = jdbcTemplate.query(dataSql, dataArgs.toArray(), this::mapPersonOption);
        return PageResponse.of(rows, total == null ? 0 : total.intValue(), pageSize, pageIndex);
    }

    public List<TerminalCardCompanyOptionCO> listCompanyOptions(TerminalCardCompanyOptionQry qry) {
        TerminalCardCompanyOptionQry query = qry == null ? new TerminalCardCompanyOptionQry() : qry;
        List<Object> args = new ArrayList<>();
        StringBuilder sql = new StringBuilder("SELECT corpinfo_id, corp_name, code, contacts, lr_name FROM corp_info "
                + "WHERE (delete_enum IS NULL OR delete_enum = 'FALSE')");
        if (StringUtils.hasText(query.getKeyword())) {
            sql.append(" AND (corp_name LIKE ? OR code LIKE ? OR contacts LIKE ? OR lr_name LIKE ?)");
            String keyword = "%" + query.getKeyword().trim() + "%";
            args.add(keyword);
            args.add(keyword);
            args.add(keyword);
            args.add(keyword);
        }
        sql.append(" ORDER BY corp_name ASC LIMIT 200");
        return jdbcTemplate.query(sql.toString(), args.toArray(), this::mapCompanyOption);
    }

    private String buildCompanyFromWhere(TerminalCardCompanyPageQry qry, List<Object> args) {
        StringBuilder sql = new StringBuilder(" FROM terminal t LEFT JOIN corp_info ci ON ("
                + "CAST(ci.corpinfo_id AS CHAR) = CAST(t.corpinfo_id AS CHAR)"
                + " OR (t.org_code IS NOT NULL AND t.org_code <> '' AND ci.code = t.org_code)"
                + " OR (t.corpinfo_name IS NOT NULL AND t.corpinfo_name <> '' AND ci.corp_name = t.corpinfo_name)"
                + " OR (t.org_name IS NOT NULL AND t.org_name <> '' AND ci.corp_name = t.org_name))"
                + " WHERE (t.delete_enum IS NULL OR t.delete_enum = 'FALSE') AND t.device_type = ?");
        args.add(DEVICE_TYPE_CARD);
        if (qry.getEqCorpinfoId() != null) {
            sql.append(" AND t.corpinfo_id = ?");
            args.add(qry.getEqCorpinfoId());
        }
        if (StringUtils.hasText(qry.getLikeCorpinfoName())) {
            sql.append(" AND (t.corpinfo_name LIKE ? OR t.org_name LIKE ? OR ci.corp_name LIKE ?)");
            String keyword = "%" + qry.getLikeCorpinfoName().trim() + "%";
            args.add(keyword);
            args.add(keyword);
            args.add(keyword);
        }
        if (StringUtils.hasText(qry.getLikeContactName())) {
            sql.append(" AND (ci.contacts LIKE ? OR ci.lr_name LIKE ?)");
            String keyword = "%" + qry.getLikeContactName().trim() + "%";
            args.add(keyword);
            args.add(keyword);
        }
        Integer enable = qry.getEqEnable() == null ? normalizeEnable(qry.getStatus()) : qry.getEqEnable();
        if (enable != null) {
            sql.append(" AND t.enable = ?");
            args.add(enable);
        }
        return sql.toString();
    }

    private String buildCardSelect() {
        return "SELECT t.id, t.terminal_no, t.terminal_name, t.terminal_model, t.sim_card_no, "
                + "t.corpinfo_id, COALESCE(NULLIF(t.corpinfo_name, ''), NULLIF(t.org_name, ''), ci.corp_name) AS corpinfo_name, "
                + "t.org_code, t.org_name, t.enable, t.device_status, t.bind_id_card_no, "
                + "t.finds_terminal_id, t.last_location_time, t.last_location_name, t.last_sync_time, "
                + "t.sync_status, t.remarks, tb.bind_status, "
                + "COALESCE(tb.id_card_no, t.bind_id_card_no) AS active_id_card_no, "
                + "COALESCE(tb.staff_no_snapshot, u.username) AS staff_no, "
                + "COALESCE(u.name, tb.staff_name_snapshot) AS staff_name, u.phone AS staff_phone ";
    }

    private String buildCardFromWhere(TerminalCardPageQry qry, List<Object> args, Long fixedId) {
        StringBuilder sql = new StringBuilder(" FROM terminal t "
                + "LEFT JOIN terminal_bind tb ON tb.id = ("
                + "SELECT tb2.id FROM terminal_bind tb2 WHERE tb2.terminal_no = t.terminal_no "
                + "AND (tb2.delete_enum IS NULL OR tb2.delete_enum = 'FALSE') "
                + "AND tb2.bind_status = 'BOUND' ORDER BY tb2.bind_time DESC, tb2.id DESC LIMIT 1) "
                + "LEFT JOIN user_scope_v u ON u.user_id_card = COALESCE(tb.id_card_no, t.bind_id_card_no) "
                + "AND (u.delete_enum IS NULL OR u.delete_enum = 'FALSE') "
                + "LEFT JOIN corp_info ci ON (CAST(ci.corpinfo_id AS CHAR) = CAST(t.corpinfo_id AS CHAR) "
                + "OR (t.org_code IS NOT NULL AND t.org_code <> '' AND ci.code = t.org_code) "
                + "OR (t.corpinfo_name IS NOT NULL AND t.corpinfo_name <> '' AND ci.corp_name = t.corpinfo_name) "
                + "OR (t.org_name IS NOT NULL AND t.org_name <> '' AND ci.corp_name = t.org_name)) "
                + "WHERE (t.delete_enum IS NULL OR t.delete_enum = 'FALSE') AND t.device_type = ?");
        args.add(DEVICE_TYPE_CARD);
        if (fixedId != null) {
            sql.append(" AND t.id = ?");
            args.add(fixedId);
        }
        applyCardFilters(qry, sql, args);
        return sql.toString();
    }

    private void applyCardFilters(TerminalCardPageQry qry, StringBuilder sql, List<Object> args) {
        if (StringUtils.hasText(qry.getTerminalNo())) {
            sql.append(" AND t.terminal_no LIKE ?");
            args.add("%" + qry.getTerminalNo().trim() + "%");
        }
        if (StringUtils.hasText(qry.getStaffName())) {
            sql.append(" AND (u.name LIKE ? OR tb.staff_name_snapshot LIKE ?)");
            String keyword = "%" + qry.getStaffName().trim() + "%";
            args.add(keyword);
            args.add(keyword);
        }
        if (StringUtils.hasText(qry.getStaffNo())) {
            sql.append(" AND (u.username = ? OR tb.staff_no_snapshot = ?)");
            args.add(qry.getStaffNo().trim());
            args.add(qry.getStaffNo().trim());
        }
        if (StringUtils.hasText(qry.getIdCardNo())) {
            sql.append(" AND COALESCE(tb.id_card_no, t.bind_id_card_no) = ?");
            args.add(qry.getIdCardNo().trim());
        }
        String requestedCorpinfoId = qry.getEqCorpinfoId();
        boolean hasEqCorpinfoName = StringUtils.hasText(qry.getEqCorpinfoName());
        boolean hasLikeCorpinfoName = StringUtils.hasText(qry.getLikeCorpinfoName());
        boolean hasExplicitCorpinfoFilter = requestedCorpinfoId != null
                || hasEqCorpinfoName || hasLikeCorpinfoName;
        String corpinfoId = hasExplicitCorpinfoFilter ? requestedCorpinfoId : currentCompanyId();
        if (requestedCorpinfoId != null && hasEqCorpinfoName) {
            String corpinfoName = qry.getEqCorpinfoName().trim();
            sql.append(" AND (t.corpinfo_id = ? OR (t.corpinfo_id IS NULL"
                    + " AND (t.corpinfo_name = ? OR t.org_name = ? OR ci.corp_name = ?)))");
            args.add(requestedCorpinfoId);
            args.add(corpinfoName);
            args.add(corpinfoName);
            args.add(corpinfoName);
        } else if (corpinfoId != null) {
            sql.append(" AND t.corpinfo_id = ?");
            args.add(corpinfoId);
        }
        if (requestedCorpinfoId == null && hasEqCorpinfoName) {
            sql.append(" AND (t.corpinfo_name = ? OR t.org_name = ? OR ci.corp_name = ?)");
            String corpinfoName = qry.getEqCorpinfoName().trim();
            args.add(corpinfoName);
            args.add(corpinfoName);
            args.add(corpinfoName);
        }
        if (hasLikeCorpinfoName) {
            sql.append(" AND (t.corpinfo_name LIKE ? OR t.org_name LIKE ? OR ci.corp_name LIKE ?)");
            String keyword = "%" + qry.getLikeCorpinfoName().trim() + "%";
            args.add(keyword);
            args.add(keyword);
            args.add(keyword);
        }
        if (StringUtils.hasText(qry.getOrgCode())) {
            sql.append(" AND t.org_code = ?");
            args.add(qry.getOrgCode().trim());
        }
        if (StringUtils.hasText(qry.getOrgName())) {
            sql.append(" AND t.org_name LIKE ?");
            args.add("%" + qry.getOrgName().trim() + "%");
        }
        if (qry.getEnable() != null) {
            sql.append(" AND t.enable = ?");
            args.add(qry.getEnable());
        }
        if (StringUtils.hasText(qry.getDeviceStatus())) {
            sql.append(" AND t.device_status = ?");
            args.add(qry.getDeviceStatus().trim());
        }
    }

    private String buildPersonOptionFromWhere(TerminalCardPersonOptionQry qry, List<Object> args) {
        StringBuilder sql = new StringBuilder(" FROM user_scope_v u LEFT JOIN corp_info ci "
                + "ON CAST(ci.corpinfo_id AS CHAR) = CAST(u.corpinfo_id AS CHAR) "
                + "WHERE (u.delete_enum IS NULL OR u.delete_enum = 'FALSE')");
        if (StringUtils.hasText(qry.getKeyword())) {
            sql.append(" AND (u.name LIKE ? OR u.username LIKE ? OR u.user_id_card LIKE ? OR u.phone LIKE ?)");
            String keyword = "%" + qry.getKeyword().trim() + "%";
            args.add(keyword);
            args.add(keyword);
            args.add(keyword);
            args.add(keyword);
        }
        if (StringUtils.hasText(qry.getStaffName())) {
            sql.append(" AND u.name LIKE ?");
            args.add("%" + qry.getStaffName().trim() + "%");
        }
        if (StringUtils.hasText(qry.getStaffNo())) {
            sql.append(" AND u.username = ?");
            args.add(qry.getStaffNo().trim());
        }
        if (StringUtils.hasText(qry.getIdCardNo())) {
            sql.append(" AND u.user_id_card = ?");
            args.add(qry.getIdCardNo().trim());
        }
        String corpinfoId = firstText(qry.getCorpinfoId(), currentCompanyId());
        if (corpinfoId != null) {
            sql.append(" AND u.corpinfo_id = ?");
            args.add(corpinfoId);
        }
        if (StringUtils.hasText(qry.getCorpinfoName())) {
            sql.append(" AND ci.corp_name LIKE ?");
            args.add("%" + qry.getCorpinfoName().trim() + "%");
        }
        return sql.toString();
    }

    private Map<String, Object> buildTerminalListRequest(TerminalCardSyncCmd cmd, int pageNo, int pageSize) {
        Map<String, Object> request = new HashMap<>();
        putIfText(request, "terminalNo", cmd.getTerminalNo());
        putIfText(request, "orgCode", cmd.getOrgCode());
        putIfText(request, "orgName", firstText(cmd.getOrgName(), cmd.getCorpinfoName()));
        request.put("pageNo", pageNo);
        request.put("pageSize", pageSize);
        return request;
    }

    private SyncRowResult upsertSyncedCard(JsonNode row, TerminalCardSyncCmd cmd, LocalDateTime syncTime) {
        String terminalNo = firstText(textValue(child(row, "terminalNo")), textValue(child(row, "no")),
                textValue(child(row, "terminalCode")), textValue(child(row, "imei")));
        SyncRowResult result = new SyncRowResult();
        if (!StringUtils.hasText(terminalNo)) {
            result.skipped = true;
            return result;
        }
        if (!looksLikeCardTerminal(row)) {
            result.skipped = true;
            return result;
        }
        TerminalDO terminal = queryTerminalByNo(terminalNo);
        if (terminal != null && isPendingLocalSync(terminal.getSyncStatus())) {
            result.skipped = true;
            return result;
        }
        result.inserted = terminal == null;
        if (terminal == null) {
            terminal = new TerminalDO();
            terminal.setTerminalNo(terminalNo);
        }
        JsonNode track = firstPresentAny(child(row, "track"), child(row, "bindTrack"), child(row, "staff"));
        CorpInfo corpInfo = resolveCorpInfo(cmd.getCorpinfoId(),
                firstText(textValue(child(row, "corpinfoName")), textValue(child(row, "corpName")),
                        textValue(child(row, "orgName")), cmd.getCorpinfoName()),
                firstText(textValue(child(row, "orgCode")), textValue(child(row, "orgId")), cmd.getOrgCode()),
                firstText(textValue(child(row, "orgName")), cmd.getOrgName(), cmd.getCorpinfoName()));

        terminal.setTerminalName(firstText(textValue(child(row, "terminalName")), textValue(child(row, "name")),
                terminal.getTerminalName(), terminalNo));
        terminal.setTerminalModel(firstText(textValue(child(row, "terminalModel")), textValue(child(row, "model")),
                terminal.getTerminalModel()));
        terminal.setDeviceType(DEVICE_TYPE_CARD);
        terminal.setSimCardNo(firstText(textValue(child(row, "simCardNo")), textValue(child(row, "simNo")),
                terminal.getSimCardNo()));
        terminal.setCorpinfoId(corpInfo == null ? firstText(cmd.getCorpinfoId(), terminal.getCorpinfoId()) : corpInfo.corpinfoId);
        terminal.setCorpinfoName(firstText(cmd.getCorpinfoName(), corpInfo == null ? null : corpInfo.corpName,
                textValue(child(row, "corpinfoName")), textValue(child(row, "corpName")),
                textValue(child(row, "orgName")), terminal.getCorpinfoName()));
        terminal.setOrgCode(firstText(textValue(child(row, "orgCode")), textValue(child(row, "orgId")),
                cmd.getOrgCode(), terminal.getOrgCode()));
        terminal.setOrgName(firstText(textValue(child(row, "orgName")), cmd.getOrgName(), terminal.getOrgName(),
                terminal.getCorpinfoName()));
        terminal.setEnable(firstInteger(integerValue(firstPresentAny(child(row, "enable"), child(row, "enabled"))),
                terminal.getEnable(), 1));
        terminal.setDeviceStatus(firstText(textValue(child(row, "deviceStatus")), textValue(child(row, "status")),
                textValue(child(row, "onlineStatus")), terminal.getDeviceStatus()));
        terminal.setFindsTerminalId(firstLong(longValue(firstPresentAny(child(row, "id"), child(row, "terminalId"),
                child(row, "findsTerminalId"))), terminal.getFindsTerminalId()));
        terminal.setLastLocationTime(firstTime(timeValue(firstPresentAny(child(row, "lastReportTime"),
                child(row, "lastLocationTime"), child(row, "lastOnlineTime"),
                child(child(row, "lastLocation"), "time"), child(track, "lastReportTime"))),
                terminal.getLastLocationTime()));
        terminal.setLastLocationName(firstText(locationName(row), locationName(child(row, "lastLocation")),
                locationName(track), terminal.getLastLocationName()));
        terminal.setSyncStatus(SYNC_STATUS_SUCCESS);
        terminal.setLastSyncTime(syncTime);
        terminal.setRawJson(jsonValue(row));
        if (result.inserted) {
            terminalRepository.save(terminal);
        } else {
            terminalRepository.updateById(terminal);
        }

        PersonSnapshot person = personFromTrack(track);
        if (!hasPerson(person)) {
            person = personFromTrack(row);
        }
        if (hasPerson(person)) {
            result.bindingSynced = upsertActiveBinding(terminal, person, false, syncTime);
        }
        return result;
    }

    private void applyUpdate(TerminalCardUpdateCmd cmd, TerminalDO terminal, CorpInfo corpInfo) {
        if (StringUtils.hasText(cmd.getTerminalNo())) {
            terminal.setTerminalNo(cmd.getTerminalNo().trim());
        }
        if (cmd.getTerminalName() != null) {
            terminal.setTerminalName(cmd.getTerminalName());
        }
        if (cmd.getTerminalModel() != null) {
            terminal.setTerminalModel(cmd.getTerminalModel());
        }
        if (cmd.getSimCardNo() != null) {
            terminal.setSimCardNo(cmd.getSimCardNo());
        }
        if (cmd.getCheckCode() != null) {
            terminal.setCheckCode(cmd.getCheckCode());
        }
        if (cmd.getCorpinfoId() != null || corpInfo != null) {
            terminal.setCorpinfoId(corpInfo == null ? cmd.getCorpinfoId() : corpInfo.corpinfoId);
        }
        if (cmd.getCorpinfoName() != null || corpInfo != null) {
            terminal.setCorpinfoName(firstText(cmd.getCorpinfoName(), corpInfo == null ? null : corpInfo.corpName));
        }
        if (cmd.getOrgCode() != null || corpInfo != null) {
            terminal.setOrgCode(firstText(cmd.getOrgCode(), corpInfo == null ? null : corpInfo.creditCode));
        }
        if (cmd.getOrgName() != null || cmd.getCorpinfoName() != null || corpInfo != null) {
            terminal.setOrgName(firstText(cmd.getOrgName(), cmd.getCorpinfoName(), corpInfo == null ? null : corpInfo.corpName));
        }
        if (cmd.getEnable() != null) {
            terminal.setEnable(cmd.getEnable());
        }
        if (cmd.getRemarks() != null) {
            terminal.setRemarks(cmd.getRemarks());
        }
    }

    private boolean upsertActiveBinding(TerminalDO terminal, PersonSnapshot person, boolean callFinds, LocalDateTime now) {
        if (!hasPerson(person)) {
            return false;
        }
        if (!callFinds && hasPendingBinding(terminal.getTerminalNo())) {
            return false;
        }
        PersonSnapshot normalized = mergePerson(person, resolvePerson(person.idCardNo, person.staffNo, person.staffName));
        TerminalBindDO active = queryActiveBinding(terminal.getTerminalNo());
        if (active != null && samePerson(active, normalized)) {
            active.setIdCardNo(firstText(normalized.idCardNo, active.getIdCardNo()));
            active.setStaffNoSnapshot(firstText(normalized.staffNo, active.getStaffNoSnapshot()));
            active.setStaffNameSnapshot(firstText(normalized.staffName, active.getStaffNameSnapshot()));
            active.setFindsTrackId(firstLong(normalized.findsTrackId, active.getFindsTrackId()));
            active.setFindsTerminalId(firstLong(terminal.getFindsTerminalId(), active.getFindsTerminalId()));
            markBindingSyncStatus(active, callFinds, now);
            terminalBindRepository.updateById(active);
            updateTerminalBindIdCardNo(terminal, active.getIdCardNo());
            return true;
        }
        if (active != null) {
            active.setBindStatus(BIND_STATUS_UNBOUND);
            active.setUnbindTime(now);
            markBindingSyncStatus(active, callFinds, now);
            terminalBindRepository.updateById(active);
        }
        TerminalBindDO bind = new TerminalBindDO();
        bind.setIdCardNo(normalized.idCardNo);
        bind.setStaffNoSnapshot(normalized.staffNo);
        bind.setStaffNameSnapshot(normalized.staffName);
        bind.setTerminalId(terminal.getId());
        bind.setTerminalNo(terminal.getTerminalNo());
        bind.setDeviceType(DEVICE_TYPE_CARD);
        bind.setCorpinfoId(terminal.getCorpinfoId());
        bind.setCorpinfoName(terminal.getCorpinfoName());
        bind.setTrackType(TRACK_TYPE_STAFF);
        bind.setCheckCode(terminal.getCheckCode());
        bind.setBindStatus(BIND_STATUS_BOUND);
        bind.setBindTime(now);
        bind.setFindsTrackId(normalized.findsTrackId);
        bind.setFindsTerminalId(terminal.getFindsTerminalId());
        markBindingSyncStatus(bind, callFinds, now);
        terminalBindRepository.save(bind);
        updateTerminalBindIdCardNo(terminal, normalized.idCardNo);
        return true;
    }

    private void unbindActiveBinding(TerminalDO terminal, boolean callFinds, LocalDateTime now) {
        TerminalBindDO active = queryActiveBinding(terminal.getTerminalNo());
        if (active == null) {
            updateTerminalBindIdCardNo(terminal, null);
            return;
        }
        active.setBindStatus(BIND_STATUS_UNBOUND);
        active.setUnbindTime(now);
        markBindingSyncStatus(active, callFinds, now);
        terminalBindRepository.updateById(active);
        updateTerminalBindIdCardNo(terminal, null);
    }

    private void markBindingSyncStatus(TerminalBindDO binding, boolean localChange, LocalDateTime now) {
        binding.setSyncStatus(localChange ? SYNC_STATUS_WAIT : SYNC_STATUS_SUCCESS);
        binding.setSyncToken(localChange ? FindsSyncToken.next() : null);
        binding.setLastSyncTime(localChange ? null : now);
    }

    private boolean hasPendingBinding(String terminalNo) {
        if (!StringUtils.hasText(terminalNo)) {
            return false;
        }
        QueryWrapper<TerminalBindDO> wrapper = new QueryWrapper<>();
        wrapper.eq("terminal_no", terminalNo);
        wrapper.in("sync_status", FindsPendingSyncService.STATUS_WAIT, FindsPendingSyncService.STATUS_FAIL,
                FindsPendingSyncService.STATUS_DELETE_WAIT, FindsPendingSyncService.STATUS_DELETE_FAIL);
        wrapper.last("LIMIT 1");
        return terminalBindRepository.getOne(wrapper, false) != null;
    }

    private boolean isPendingLocalSync(String syncStatus) {
        return FindsPendingSyncService.STATUS_WAIT.equals(syncStatus)
                || FindsPendingSyncService.STATUS_FAIL.equals(syncStatus)
                || FindsPendingSyncService.STATUS_DELETE_WAIT.equals(syncStatus)
                || FindsPendingSyncService.STATUS_DELETE_FAIL.equals(syncStatus);
    }

    private void updateTerminalBindIdCardNo(TerminalDO terminal, String idCardNo) {
        jdbcTemplate.update("UPDATE terminal SET bind_id_card_no = ? WHERE id = ?", idCardNo, terminal.getId());
        terminal.setBindIdCardNo(idCardNo);
    }

    private void moveActiveBindingTerminalNo(String oldTerminalNo, TerminalDO terminal) {
        TerminalBindDO active = queryActiveBinding(oldTerminalNo);
        if (active == null) {
            return;
        }
        active.setTerminalNo(terminal.getTerminalNo());
        active.setTerminalId(terminal.getId());
        active.setFindsTerminalId(terminal.getFindsTerminalId());
        terminalBindRepository.updateById(active);
    }

    private TerminalBindDO queryActiveBinding(String terminalNo) {
        if (!StringUtils.hasText(terminalNo)) {
            return null;
        }
        QueryWrapper<TerminalBindDO> wrapper = new QueryWrapper<>();
        wrapper.eq("terminal_no", terminalNo);
        wrapper.eq("bind_status", BIND_STATUS_BOUND);
        wrapper.eq("delete_enum", "FALSE");
        wrapper.orderByDesc("bind_time", "id");
        wrapper.last("LIMIT 1");
        return terminalBindRepository.getOne(wrapper, false);
    }

    private TerminalDO queryTerminalById(Long id) {
        QueryWrapper<TerminalDO> wrapper = new QueryWrapper<>();
        wrapper.eq("id", id);
        wrapper.eq("delete_enum", "FALSE");
        wrapper.last("LIMIT 1");
        return terminalRepository.getOne(wrapper, false);
    }

    private TerminalDO queryTerminalByNo(String terminalNo) {
        if (!StringUtils.hasText(terminalNo)) {
            return null;
        }
        QueryWrapper<TerminalDO> wrapper = new QueryWrapper<>();
        wrapper.eq("terminal_no", terminalNo.trim());
        wrapper.eq("delete_enum", "FALSE");
        wrapper.last("LIMIT 1");
        return terminalRepository.getOne(wrapper, false);
    }

    private PersonSnapshot resolvePerson(String idCardNo, String staffNo, String staffName) {
        List<Object> args = new ArrayList<>();
        StringBuilder sql = new StringBuilder("SELECT u.id AS user_id, u.username AS staff_no, u.name AS staff_name, "
                + "u.user_id_card AS id_card_no, u.phone AS mobile_no, u.corpinfo_id AS corpinfo_id, "
                + "ci.corp_name AS corpinfo_name FROM user_scope_v u LEFT JOIN corp_info ci "
                + "ON CAST(ci.corpinfo_id AS CHAR) = CAST(u.corpinfo_id AS CHAR) "
                + "WHERE (u.delete_enum IS NULL OR u.delete_enum = 'FALSE')");
        if (StringUtils.hasText(idCardNo)) {
            sql.append(" AND u.user_id_card = ?");
            args.add(idCardNo.trim());
        } else if (StringUtils.hasText(staffNo)) {
            sql.append(" AND u.username = ?");
            args.add(staffNo.trim());
        } else if (StringUtils.hasText(staffName)) {
            sql.append(" AND u.name = ?");
            args.add(staffName.trim());
        } else {
            return null;
        }
        sql.append(" LIMIT 1");
        List<PersonSnapshot> rows = jdbcTemplate.query(sql.toString(), args.toArray(), this::mapPersonSnapshot);
        PersonSnapshot fallback = new PersonSnapshot();
        fallback.idCardNo = clean(idCardNo);
        fallback.staffNo = clean(staffNo);
        fallback.staffName = clean(staffName);
        return rows.isEmpty() ? fallback : mergePerson(rows.get(0), fallback);
    }

    private CorpInfo resolveCorpInfo(String corpinfoId, String corpinfoName, String orgCode, String orgName) {
        List<Object> args = new ArrayList<>();
        StringBuilder sql = new StringBuilder("SELECT corpinfo_id, corp_name, code, contacts, lr_name FROM corp_info "
                + "WHERE (delete_enum IS NULL OR delete_enum = 'FALSE')");
        if (corpinfoId != null) {
            sql.append(" AND corpinfo_id = ?");
            args.add(corpinfoId);
        } else if (StringUtils.hasText(orgCode)) {
            sql.append(" AND code = ?");
            args.add(orgCode.trim());
        } else if (StringUtils.hasText(corpinfoName) || StringUtils.hasText(orgName)) {
            sql.append(" AND corp_name = ?");
            args.add(firstText(corpinfoName, orgName).trim());
        } else {
            return null;
        }
        sql.append(" LIMIT 1");
        List<CorpInfo> rows = jdbcTemplate.query(sql.toString(), args.toArray(), this::mapCorpInfo);
        return rows.isEmpty() ? null : rows.get(0);
    }

    private TerminalCardCompanyCO mapCompany(ResultSet rs, int rowNum) throws SQLException {
        TerminalCardCompanyCO co = new TerminalCardCompanyCO();
        String corpinfoId = rs.getString("corpinfo_id");
        co.setCorpinfoId(rs.wasNull() ? null : corpinfoId);
        co.setCorpinfoName(rs.getString("corpinfo_name"));
        co.setOrgCode(rs.getString("org_code"));
        co.setOrgName(rs.getString("org_name"));
        co.setContactName(rs.getString("contact_name"));
        co.setCardCount(rs.getInt("card_count"));
        co.setEnabledCount(rs.getInt("enabled_count"));
        Timestamp lastSyncTime = rs.getTimestamp("last_sync_time");
        co.setLastSyncTime(lastSyncTime == null ? null : lastSyncTime.toLocalDateTime());
        return co;
    }

    private TerminalCardCO mapCard(ResultSet rs, int rowNum) throws SQLException {
        TerminalCardCO co = new TerminalCardCO();
        co.setId(rs.getLong("id"));
        co.setTerminalNo(rs.getString("terminal_no"));
        co.setTerminalName(rs.getString("terminal_name"));
        co.setTerminalModel(rs.getString("terminal_model"));
        co.setSimCardNo(rs.getString("sim_card_no"));
        String corpinfoId = rs.getString("corpinfo_id");
        co.setCorpinfoId(rs.wasNull() ? null : corpinfoId);
        co.setCorpinfoName(rs.getString("corpinfo_name"));
        co.setOrgCode(rs.getString("org_code"));
        co.setOrgName(rs.getString("org_name"));
        int enable = rs.getInt("enable");
        co.setEnable(rs.wasNull() ? null : enable);
        co.setEnableName(enable == 1 ? "启用" : "停用");
        co.setDeviceStatus(rs.getString("device_status"));
        co.setBindIdCardNo(rs.getString("active_id_card_no"));
        co.setStaffNo(rs.getString("staff_no"));
        co.setStaffName(rs.getString("staff_name"));
        co.setStaffPhone(rs.getString("staff_phone"));
        co.setBindStatus(rs.getString("bind_status"));
        long findsTerminalId = rs.getLong("finds_terminal_id");
        co.setFindsTerminalId(rs.wasNull() ? null : findsTerminalId);
        co.setLastLocationTime(toLocalDateTime(rs.getTimestamp("last_location_time")));
        co.setLastLocationName(rs.getString("last_location_name"));
        co.setLastSyncTime(toLocalDateTime(rs.getTimestamp("last_sync_time")));
        co.setSyncStatus(rs.getString("sync_status"));
        co.setRemarks(rs.getString("remarks"));
        return co;
    }

    private TerminalCardPersonOptionCO mapPersonOption(ResultSet rs, int rowNum) throws SQLException {
        TerminalCardPersonOptionCO co = new TerminalCardPersonOptionCO();
        long userId = rs.getLong("user_id");
        co.setUserId(rs.wasNull() ? null : userId);
        co.setStaffNo(rs.getString("staff_no"));
        co.setStaffName(rs.getString("staff_name"));
        co.setIdCardNo(rs.getString("id_card_no"));
        co.setMobileNo(rs.getString("mobile_no"));
        String corpinfoId = rs.getString("corpinfo_id");
        co.setCorpinfoId(rs.wasNull() ? null : corpinfoId);
        co.setCorpinfoName(rs.getString("corpinfo_name"));
        long departmentId = rs.getLong("department_id");
        co.setDepartmentId(rs.wasNull() ? null : departmentId);
        co.setPostName(rs.getString("post_name"));
        return co;
    }

    private TerminalCardCompanyOptionCO mapCompanyOption(ResultSet rs, int rowNum) throws SQLException {
        TerminalCardCompanyOptionCO co = new TerminalCardCompanyOptionCO();
        String corpinfoId = rs.getString("corpinfo_id");
        co.setCorpinfoId(rs.wasNull() ? null : corpinfoId);
        co.setCorpinfoName(rs.getString("corp_name"));
        co.setCreditCode(rs.getString("code"));
        co.setContactName(firstText(rs.getString("contacts"), rs.getString("lr_name")));
        return co;
    }

    private PersonSnapshot mapPersonSnapshot(ResultSet rs, int rowNum) throws SQLException {
        PersonSnapshot person = new PersonSnapshot();
        person.staffNo = rs.getString("staff_no");
        person.staffName = rs.getString("staff_name");
        person.idCardNo = rs.getString("id_card_no");
        person.mobileNo = rs.getString("mobile_no");
        String corpinfoId = rs.getString("corpinfo_id");
        person.corpinfoId = rs.wasNull() ? null : corpinfoId;
        person.corpinfoName = rs.getString("corpinfo_name");
        return person;
    }

    private CorpInfo mapCorpInfo(ResultSet rs, int rowNum) throws SQLException {
        CorpInfo corpInfo = new CorpInfo();
        String corpinfoId = rs.getString("corpinfo_id");
        corpInfo.corpinfoId = rs.wasNull() ? null : corpinfoId;
        corpInfo.corpName = rs.getString("corp_name");
        corpInfo.creditCode = rs.getString("code");
        corpInfo.contactName = firstText(rs.getString("contacts"), rs.getString("lr_name"));
        return corpInfo;
    }

    private PersonSnapshot personFromTrack(JsonNode node) {
        if (isEmpty(node)) {
            return null;
        }
        PersonSnapshot person = new PersonSnapshot();
        person.findsTrackId = longValue(firstPresentAny(child(node, "id"), child(node, "trackId"),
                child(node, "staffId"), child(node, "findsTrackId")));
        person.idCardNo = textValue(firstPresentAny(child(node, "idCardNo"), child(node, "identityNo"),
                child(node, "certificateNo"), child(node, "cardNo")));
        person.staffNo = textValue(firstPresentAny(child(node, "staffNo"), child(node, "code"),
                child(node, "employeeNo"), child(node, "workNo")));
        person.staffName = textValue(firstPresentAny(child(node, "staffName"), child(node, "name"),
                child(node, "realName"), child(node, "userName")));
        person.mobileNo = textValue(firstPresentAny(child(node, "mobileNo"), child(node, "phone"),
                child(node, "phoneNo")));
        return person;
    }

    private boolean looksLikeCardTerminal(JsonNode row) {
        String strongType = firstText(textValue(child(row, "deviceType")),
                textValue(child(row, "terminalType")), textValue(child(row, "type")));
        if (StringUtils.hasText(strongType)) {
            return containsCardKeyword(strongType);
        }
        JsonNode terminalBiz = firstPresentAny(child(row, "terminalBiz"), child(row, "biz"));
        String bizText = firstText(textValue(child(row, "bizCode")), textValue(child(row, "bizName")),
                textValue(child(terminalBiz, "code")), textValue(child(terminalBiz, "name")),
                textValue(child(terminalBiz, "bizCode")), textValue(child(terminalBiz, "bizName")));
        if (!StringUtils.hasText(bizText)) {
            return true;
        }
        if (containsNonCardKeyword(bizText)) {
            return false;
        }
        return true;
    }

    private boolean containsCardKeyword(String value) {
        if (!StringUtils.hasText(value)) {
            return false;
        }
        String upper = value.trim().toUpperCase();
        return upper.contains("CARD") || upper.contains("DWK") || value.contains("定位卡");
    }

    private boolean containsNonCardKeyword(String value) {
        String upper = value.trim().toUpperCase();
        return upper.contains("APP") || upper.contains("PHONE") || upper.contains("MOBILE")
                || upper.contains("CAR") || upper.contains("VEHICLE")
                || value.contains("手机") || value.contains("车辆") || value.contains("摄像")
                || value.contains("门禁") || value.contains("闸机");
    }

    private boolean samePerson(TerminalBindDO active, PersonSnapshot person) {
        if (StringUtils.hasText(active.getIdCardNo()) && StringUtils.hasText(person.idCardNo)) {
            return active.getIdCardNo().equals(person.idCardNo);
        }
        if (StringUtils.hasText(active.getStaffNoSnapshot()) && StringUtils.hasText(person.staffNo)) {
            return active.getStaffNoSnapshot().equals(person.staffNo);
        }
        return StringUtils.hasText(active.getStaffNameSnapshot()) && StringUtils.hasText(person.staffName)
                && active.getStaffNameSnapshot().equals(person.staffName);
    }

    private boolean hasPerson(PersonSnapshot person) {
        return person != null && (StringUtils.hasText(person.idCardNo)
                || StringUtils.hasText(person.staffNo) || StringUtils.hasText(person.staffName));
    }

    private PersonSnapshot mergePerson(PersonSnapshot first, PersonSnapshot second) {
        if (first == null) {
            return second;
        }
        if (second == null) {
            return first;
        }
        first.idCardNo = firstText(first.idCardNo, second.idCardNo);
        first.staffNo = firstText(first.staffNo, second.staffNo);
        first.staffName = firstText(first.staffName, second.staffName);
        first.mobileNo = firstText(first.mobileNo, second.mobileNo);
        first.corpinfoId = firstText(first.corpinfoId, second.corpinfoId);
        first.corpinfoName = firstText(first.corpinfoName, second.corpinfoName);
        first.findsTrackId = firstLong(first.findsTrackId, second.findsTrackId);
        return first;
    }

    private void validateSave(TerminalCardSaveCmd cmd) {
        if (cmd == null || !StringUtils.hasText(cmd.getTerminalNo())) {
            throw new BizException("Terminal card no is required");
        }
    }

    private JsonNode assertSuccess(JsonNode root, String apiName) {
        if (isEmpty(root)) {
            throw new BizException("FindS response is empty: " + apiName);
        }
        JsonNode codeNode = root.get("code");
        if (!isEmpty(codeNode)) {
            int code = codeNode.asInt(-1);
            if (code != 200 && code != 0) {
                String msg = firstText(root.path("msg").asText(null), root.path("message").asText(null));
                throw new BizException("FindS API failed: " + apiName + ", code=" + code + ", msg=" + msg);
            }
        }
        JsonNode successNode = root.get("success");
        if (!isEmpty(successNode) && successNode.isBoolean() && !successNode.asBoolean()) {
            String msg = firstText(root.path("msg").asText(null), root.path("message").asText(null));
            throw new BizException("FindS API failed: " + apiName + ", msg=" + msg);
        }
        return root;
    }

    private JsonNode extractRows(JsonNode data) {
        if (isEmpty(data)) {
            return objectMapper.createArrayNode();
        }
        if (data.isArray()) {
            return data;
        }
        JsonNode direct = firstPresentAny(child(data, "data"), child(data, "rows"),
                child(data, "list"), child(data, "records"));
        if (direct != null && direct.isArray()) {
            return direct;
        }
        JsonNode nested = firstPresentAny(child(child(data, "data"), "data"),
                child(child(data, "data"), "rows"),
                child(child(data, "data"), "list"),
                child(child(data, "data"), "records"));
        if (nested != null && nested.isArray()) {
            return nested;
        }
        return objectMapper.createArrayNode();
    }

    private long totalValue(JsonNode data, long defaultValue) {
        Long total = longValue(firstPresentAny(child(data, "total"), child(data, "totalCount"),
                child(data, "count"), child(data, "totalRecords")));
        return total == null ? defaultValue : total;
    }

    private Long longValue(JsonNode node) {
        if (isEmpty(node)) {
            return null;
        }
        if (node.isNumber()) {
            return node.asLong();
        }
        try {
            return Long.valueOf(node.asText());
        } catch (NumberFormatException e) {
            return null;
        }
    }

    private Integer integerValue(JsonNode node) {
        Long value = longValue(node);
        return value == null ? null : value.intValue();
    }

    private LocalDateTime timeValue(JsonNode node) {
        if (isEmpty(node)) {
            return null;
        }
        if (node.isNumber()) {
            long epoch = node.asLong();
            if (epoch < 100000000000L) {
                epoch = epoch * 1000L;
            }
            return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch), ZoneId.systemDefault());
        }
        String value = node.asText();
        if (!StringUtils.hasText(value)) {
            return null;
        }
        try {
            long epoch = Long.parseLong(value);
            if (epoch < 100000000000L) {
                epoch = epoch * 1000L;
            }
            return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch), ZoneId.systemDefault());
        } catch (NumberFormatException ignored) {
            // 数字时间戳解析失败后，继续尝试解析格式化日期。
        }
        String normalized = value.trim().replace(' ', 'T');
        try {
            return LocalDateTime.parse(normalized);
        } catch (Exception ignored) {
            try {
                return OffsetDateTime.parse(normalized).toLocalDateTime();
            } catch (Exception ignoredAgain) {
                return null;
            }
        }
    }

    private String textValue(JsonNode node) {
        if (isEmpty(node)) {
            return null;
        }
        String value = node.asText();
        return StringUtils.hasText(value) ? value : null;
    }

    private String jsonValue(JsonNode node) {
        if (isEmpty(node)) {
            return null;
        }
        try {
            return objectMapper.writeValueAsString(node);
        } catch (IOException e) {
            throw new BizException("JSON serialize failed");
        }
    }

    private JsonNode child(JsonNode node, String fieldName) {
        return isEmpty(node) || fieldName == null ? null : node.get(fieldName);
    }

    private JsonNode firstPresentAny(JsonNode... nodes) {
        if (nodes == null) {
            return null;
        }
        for (JsonNode node : nodes) {
            if (!isEmpty(node)) {
                return node;
            }
        }
        return null;
    }

    private boolean isEmpty(JsonNode node) {
        return node == null || node.isMissingNode() || node.isNull();
    }

    private String locationName(JsonNode node) {
        return firstText(textValue(child(node, "fenceName")), textValue(child(node, "areaName")),
                textValue(child(node, "positionName")), textValue(child(node, "location")),
                textValue(child(node, "address")));
    }

    private Integer normalizeEnable(String status) {
        if (!StringUtils.hasText(status)) {
            return null;
        }
        String value = status.trim().toLowerCase();
        if ("1".equals(value) || "enabled".equals(value) || "enable".equals(value) || "启用".equals(status.trim())) {
            return 1;
        }
        if ("0".equals(value) || "disabled".equals(value) || "disable".equals(value) || "停用".equals(status.trim())) {
            return 0;
        }
        return null;
    }

    private int normalizePageIndex(Integer pageIndex) {
        return pageIndex == null || pageIndex < 1 ? 1 : pageIndex;
    }

    private int normalizePageSize(Integer pageSize) {
        return pageSize == null || pageSize < 1 ? 20 : pageSize;
    }

    private int normalizePositive(Integer value, int defaultValue) {
        return value == null || value < 1 ? defaultValue : value;
    }

    private LocalDateTime toLocalDateTime(Timestamp timestamp) {
        return timestamp == null ? null : timestamp.toLocalDateTime();
    }

    private LocalDateTime firstTime(LocalDateTime... values) {
        if (values == null) {
            return null;
        }
        for (LocalDateTime value : values) {
            if (value != null) {
                return value;
            }
        }
        return null;
    }

    private String firstText(String... values) {
        if (values == null) {
            return null;
        }
        for (String value : values) {
            if (StringUtils.hasText(value)) {
                return value;
            }
        }
        return null;
    }

    private Long firstLong(Long... values) {
        if (values == null) {
            return null;
        }
        for (Long value : values) {
            if (value != null) {
                return value;
            }
        }
        return null;
    }

    private Integer firstInteger(Integer... values) {
        if (values == null) {
            return null;
        }
        for (Integer value : values) {
            if (value != null) {
                return value;
            }
        }
        return null;
    }

    private String clean(String value) {
        return StringUtils.hasText(value) ? value.trim() : null;
    }

    private void putIfText(Map<String, Object> request, String key, String value) {
        if (StringUtils.hasText(value)) {
            request.put(key, value);
        }
    }

    private String currentCompanyId() {
        SSOUser currentUser = AuthContext.getCurrentUser();
        return currentUser == null || currentUser.getCompanyId() == null
                ? null : String.valueOf(currentUser.getCompanyId());
    }

    private static class SyncRowResult {
        private boolean inserted;
        private boolean skipped;
        private boolean bindingSynced;
    }

    private static class PersonSnapshot {
        private String idCardNo;
        private String staffNo;
        private String staffName;
        private String mobileNo;
        private String corpinfoId;
        private String corpinfoName;
        private Long findsTrackId;
    }

    private static class CorpInfo {
        private String corpinfoId;
        private String corpName;
        private String creditCode;
        private String contactName;
    }
}
