package com.nanri.aiimage.modules.brand.service;

import cn.hutool.core.io.FileUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.BrandProgressProperties;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.brand.mapper.BrandCrawlTaskMapper;
import com.nanri.aiimage.modules.brand.model.dto.BrandCrawlResultFileDto;
import com.nanri.aiimage.modules.brand.model.dto.BrandCrawlResultRequest;
import com.nanri.aiimage.modules.brand.model.dto.BrandInvalidBrandDto;
import com.nanri.aiimage.modules.brand.model.dto.BrandParsedFileCacheDto;
import com.nanri.aiimage.modules.brand.model.dto.BrandQueryFailedDto;
import com.nanri.aiimage.modules.brand.model.dto.BrandSourceFileDto;
import com.nanri.aiimage.modules.brand.model.dto.BrandTaskCreateRequest;
import com.nanri.aiimage.modules.brand.model.entity.BrandCrawlTaskEntity;
import com.nanri.aiimage.modules.brand.model.vo.BrandCrawlPayloadVo;
import com.nanri.aiimage.modules.brand.model.vo.BrandTaskCreateVo;
import com.nanri.aiimage.modules.brand.model.vo.BrandTaskDetailVo;
import com.nanri.aiimage.modules.brand.model.vo.BrandTaskItemVo;
import com.nanri.aiimage.modules.brand.model.vo.BrandTaskListVo;
import com.nanri.aiimage.modules.brand.model.vo.LegacyBrandLineProgressInfoVo;
import com.nanri.aiimage.modules.brand.model.vo.LegacyBrandLineProgressVo;
import com.nanri.aiimage.modules.brand.model.vo.LegacyBrandTaskDetailVo;
import com.nanri.aiimage.modules.brand.model.vo.LegacyBrandTaskItemVo;
import com.nanri.aiimage.modules.brand.model.vo.LegacyBrandTaskListVo;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import lombok.RequiredArgsConstructor;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

@Service
@RequiredArgsConstructor
public class BrandTaskService {

    private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    private static final String STATUS_PENDING = "pending";
    private static final String STATUS_RUNNING = "running";
    private static final String STATUS_SUCCESS = "success";
    private static final String STATUS_FAILED = "failed";
    private static final String STATUS_CANCELLED = "cancelled";

    private final BrandCrawlTaskMapper brandCrawlTaskMapper;
    private final OssStorageService ossStorageService;
    private final StorageProperties storageProperties;
    private final BrandProgressProperties brandProgressProperties;
    private final BrandTaskProgressCacheService brandTaskProgressCacheService;
    private final ObjectMapper objectMapper;

    public BrandTaskCreateVo createTask(Long userId, BrandTaskCreateRequest request) {
        if (userId == null || userId <= 0) {
            throw new BusinessException("userId 不合法");
        }
        List<BrandSourceFileDto> files = normalizeFiles(request.getFiles());
        if (files.isEmpty()) {
            throw new BusinessException("请先上传待处理文件");
        }
        String strategy = normalizeStrategy(request.getStrategy());
        int taskType = normalizeTaskType(request.getTaskType(), 2);

        BrandCrawlTaskEntity entity = new BrandCrawlTaskEntity();
        entity.setUserId(userId);
        entity.setFilePaths(JSONUtil.toJsonStr(files));
        entity.setStatus(STATUS_PENDING);
        entity.setTaskType(taskType);
        entity.setResultPaths(null);
        entity.setErrorMessage(null);
        entity.setProgressCurrent(0);
        entity.setProgressTotal(0);
        entity.setStrategy(strategy);
        entity.setDesc(buildDesc(strategy, files));
        brandCrawlTaskMapper.insert(entity);

        BrandTaskCreateVo vo = new BrandTaskCreateVo();
        vo.setTaskId(entity.getId());
        return vo;
    }

    public BrandCrawlPayloadVo createTaskAndBuildPayload(Long userId, BrandTaskCreateRequest request) {
        BrandTaskCreateVo created = createTask(userId, request);
        return getCrawlPayload(created.getTaskId());
    }

    public BrandTaskListVo listTasks(Long userId) {
        List<BrandCrawlTaskEntity> entities = listTaskEntities(userId);
        BrandTaskListVo vo = new BrandTaskListVo();
        vo.setItems(entities.stream().map(this::toTaskItem).toList());
        return vo;
    }

    public LegacyBrandTaskListVo listTasksLegacy(Long userId) {
        List<BrandCrawlTaskEntity> entities = listTaskEntities(userId);
        LegacyBrandTaskListVo vo = new LegacyBrandTaskListVo();
        vo.setItems(entities.stream().map(this::toLegacyTaskItem).toList());
        return vo;
    }

    public BrandTaskDetailVo getTaskDetail(Long taskId) {
        BrandTaskDetailVo vo = new BrandTaskDetailVo();
        vo.setTask(toTaskItem(requireTask(taskId)));
        return vo;
    }

    public BrandTaskDetailVo getTaskDetail(Long taskId, Long userId) {
        BrandTaskDetailVo vo = new BrandTaskDetailVo();
        vo.setTask(toTaskItem(requireTask(taskId, userId)));
        return vo;
    }

    public LegacyBrandTaskDetailVo getTaskDetailLegacy(Long taskId) {
        BrandCrawlTaskEntity task = requireTask(taskId);
        LegacyBrandTaskDetailVo vo = new LegacyBrandTaskDetailVo();
        vo.setTask(toLegacyTaskItem(task));
        vo.setLine_progress(buildLineProgress(taskId));
        return vo;
    }

    public BrandCrawlPayloadVo getCrawlPayload(Long taskId) {
        BrandCrawlTaskEntity task = requireTask(taskId);
        if (STATUS_CANCELLED.equalsIgnoreCase(blankToDefault(task.getStatus(), STATUS_PENDING))) {
            throw new BusinessException("任务已取消");
        }
        List<BrandSourceFileDto> files = parseSourceFiles(task.getFilePaths());
        if (files.isEmpty()) {
            throw new BusinessException("任务没有源文件");
        }

        List<BrandCrawlPayloadVo.BrandCrawlPayloadFileVo> payloadFiles = new ArrayList<>();
        List<BrandParsedFileCacheDto> cachedFiles = new ArrayList<>();
        int index = 0;
        for (BrandSourceFileDto sourceFile : files) {
            index++;
            File inputFile = resolveSourceFile(sourceFile);
            ParsedBrandFile parsed = parseBrandFile(inputFile);

            BrandCrawlPayloadVo.BrandCrawlPayloadFileVo item = new BrandCrawlPayloadVo.BrandCrawlPayloadFileVo();
            item.setFileIndex(index);
            item.setFileUrl(sourceFile.getFileUrl());
            item.setOriginalFilename(resolveOriginalFilename(sourceFile, inputFile));
            item.setRelativePath(sourceFile.getRelativePath());
            item.setSheetName(parsed.sheetName());
            item.setColumns(parsed.columns());
            item.setRows(parsed.rows());
            item.setUniqueBrands(parsed.uniqueBrands());
            payloadFiles.add(item);

            BrandParsedFileCacheDto cacheDto = new BrandParsedFileCacheDto();
            cacheDto.setFileUrl(item.getFileUrl());
            cacheDto.setOriginalFilename(item.getOriginalFilename());
            cacheDto.setRelativePath(item.getRelativePath());
            cacheDto.setSheetName(item.getSheetName());
            cacheDto.setColumns(item.getColumns());
            cacheDto.setRows(item.getRows());
            cachedFiles.add(cacheDto);
        }

        brandTaskProgressCacheService.saveParsedPayload(taskId, cachedFiles);

        brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
                .eq(BrandCrawlTaskEntity::getId, taskId)
                .ne(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED)
                .set(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING)
                .set(BrandCrawlTaskEntity::getProgressTotal, payloadFiles.size())
                .set(BrandCrawlTaskEntity::getProgressCurrent, 0)
                .set(BrandCrawlTaskEntity::getErrorMessage, null));
        brandTaskProgressCacheService.updatePhase(taskId, BrandTaskProgressCacheService.PHASE_CRAWLING, 0, payloadFiles.size());

        BrandCrawlPayloadVo vo = new BrandCrawlPayloadVo();
        vo.setTaskId(taskId);
        vo.setStrategy(normalizeStrategy(task.getStrategy()));
        vo.setFiles(payloadFiles);
        return vo;
    }

    public void submitCrawlResult(Long taskId, BrandCrawlResultRequest request) {
        BrandCrawlTaskEntity task = requireTask(taskId);
        if (STATUS_CANCELLED.equalsIgnoreCase(blankToDefault(task.getStatus(), STATUS_PENDING))) {
            throw new BusinessException("任务已取消");
        }
        List<BrandSourceFileDto> sourceFiles = parseSourceFiles(task.getFilePaths());
        if (sourceFiles.isEmpty()) {
            throw new BusinessException("任务没有源文件");
        }

        Map<String, BrandSourceFileDto> sourceByUrl = new LinkedHashMap<>();
        for (BrandSourceFileDto file : sourceFiles) {
            sourceByUrl.put(file.getFileUrl(), file);
        }
        List<BrandParsedFileCacheDto> cachedFiles = brandTaskProgressCacheService.getParsedPayload(taskId,
                new TypeReference<List<BrandParsedFileCacheDto>>() {
                });
        if (cachedFiles == null || cachedFiles.isEmpty()) {
            throw new BusinessException("任务原始数据已过期，请重新创建任务");
        }
        Map<String, BrandParsedFileCacheDto> cachedByUrl = new LinkedHashMap<>();
        for (BrandParsedFileCacheDto cachedFile : cachedFiles) {
            cachedByUrl.put(cachedFile.getFileUrl(), cachedFile);
        }

        List<BrandCrawlResultFileDto> resultFiles = request.getFiles() == null ? List.of() : request.getFiles();
        if (resultFiles.isEmpty()) {
            throw new BusinessException("files 不能为空");
        }
        ensureNoDuplicateResultFiles(resultFiles);

        int totalCount = sourceFiles.size();
        int started = brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
                .eq(BrandCrawlTaskEntity::getId, taskId)
                .ne(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED)
                .set(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING)
                .set(BrandCrawlTaskEntity::getProgressTotal, totalCount)
                .set(BrandCrawlTaskEntity::getUpdatedAt, LocalDateTime.now())
                .set(BrandCrawlTaskEntity::getErrorMessage, null));
        if (started == 0) {
            throw new BusinessException("任务已取消");
        }

        brandTaskProgressCacheService.mergeResultChunks(taskId, resultFiles);
        Map<String, List<BrandCrawlResultFileDto>> groupedResults = brandTaskProgressCacheService.groupResultChunksByFile(taskId);
        int finishedCount = countCompletedFiles(groupedResults);
        updateResultDrivenProgress(taskId, sourceFiles, sourceByUrl, groupedResults, finishedCount, totalCount);
        updateTaskProgress(taskId, finishedCount, totalCount);

        if (finishedCount < totalCount) {
            return;
        }

        brandTaskProgressCacheService.updatePhase(taskId, BrandTaskProgressCacheService.PHASE_ASSEMBLING, finishedCount, totalCount);

        File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "brand-result", String.valueOf(taskId)));
        List<OutputEntry> outputEntries = new ArrayList<>();

        try {
            for (List<BrandCrawlResultFileDto> fileChunks : groupedResults.values()) {
                BrandCrawlResultFileDto resultFile = mergeChunks(fileChunks);
                BrandSourceFileDto sourceFile = sourceByUrl.get(resultFile.getFileUrl());
                if (sourceFile == null) {
                    throw new BusinessException("存在未知 fileUrl: " + resultFile.getFileUrl());
                }
                BrandParsedFileCacheDto cachedFile = cachedByUrl.get(resultFile.getFileUrl());
                if (cachedFile == null) {
                    throw new BusinessException("缺少原始缓存数据: " + resultFile.getFileUrl());
                }
                File sourceLocalFile = resolveSourceFile(sourceFile);
                String originalFilename = resolveOriginalFilename(sourceFile, sourceLocalFile);
                File outputFile = buildNamedOutputFile(outputDir, buildResultFilename(originalFilename));
                writeBrandWorkbook(outputFile, request.getStrategy(), cachedFile, resultFile);
                outputEntries.add(new OutputEntry(
                        sourceLocalFile,
                        originalFilename,
                        outputFile,
                        originalFilename));
            }

            brandTaskProgressCacheService.updatePhase(taskId, BrandTaskProgressCacheService.PHASE_UPLOADING, finishedCount, totalCount);
            Map<String, Object> resultPaths = buildAndUploadResult(taskId, outputEntries);
            int updated = brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
                    .eq(BrandCrawlTaskEntity::getId, taskId)
                    .ne(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED)
                    .set(BrandCrawlTaskEntity::getStatus, STATUS_SUCCESS)
                    .set(BrandCrawlTaskEntity::getResultPaths, JSONUtil.toJsonStr(resultPaths))
                    .set(BrandCrawlTaskEntity::getProgressCurrent, finishedCount)
                    .set(BrandCrawlTaskEntity::getProgressTotal, totalCount)
                    .set(BrandCrawlTaskEntity::getErrorMessage, null));
            if (updated == 0) {
                throw new BusinessException("任务已取消");
            }
            brandTaskProgressCacheService.delete(taskId);
        } catch (Exception ex) {
            brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
                    .eq(BrandCrawlTaskEntity::getId, taskId)
                    .ne(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED)
                    .set(BrandCrawlTaskEntity::getStatus, STATUS_FAILED)
                    .set(BrandCrawlTaskEntity::getProgressCurrent, finishedCount)
                    .set(BrandCrawlTaskEntity::getProgressTotal, totalCount)
                    .set(BrandCrawlTaskEntity::getErrorMessage, ex.getMessage()));
            brandTaskProgressCacheService.markFailed(taskId, ex.getMessage());
            if (ex instanceof BusinessException businessException) {
                throw businessException;
            }
            throw new BusinessException(ex.getMessage());
        }
    }

    public void cancelTask(Long taskId) {
        requireTask(taskId);
        int updated = brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
                .eq(BrandCrawlTaskEntity::getId, taskId)
                .in(BrandCrawlTaskEntity::getStatus, STATUS_PENDING, STATUS_RUNNING)
                .set(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED));
        if (updated == 0) {
            throw new BusinessException("任务不存在或无法取消");
        }
        brandTaskProgressCacheService.delete(taskId);
    }

    public void deleteTask(Long taskId) {
        requireTask(taskId);
        int deleted = brandCrawlTaskMapper.delete(new LambdaQueryWrapper<BrandCrawlTaskEntity>()
                .eq(BrandCrawlTaskEntity::getId, taskId)
                .ne(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING));
        if (deleted == 0) {
            throw new BusinessException("任务不存在或正在执行中无法删除");
        }
    }

    public String resolveDownloadUrl(Long taskId) {
        BrandCrawlTaskEntity task = requireTask(taskId);
        Object raw = parseJsonValue(task.getResultPaths());
        if (!(raw instanceof Map<?, ?> map)) {
            throw new BusinessException("无结果可下载");
        }
        String stored = null;
        Object zipUrl = map.get("zip_url");
        if (zipUrl instanceof String zip && !zip.isBlank()) {
            stored = zip;
        } else {
            Object urls = map.get("urls");
            if (urls instanceof List<?> list && !list.isEmpty()) {
                Object first = list.get(0);
                if (first instanceof String url && !url.isBlank()) {
                    stored = url;
                }
            }
        }
        if (stored == null || stored.isBlank()) {
            throw new BusinessException("无结果可下载");
        }
        // 统一通过 OssStorageService.generateFreshDownloadUrl 生成新鲜预签名 URL
        return ossStorageService.generateFreshDownloadUrl(stored);
    }

    private List<BrandCrawlTaskEntity> listTaskEntities(Long userId) {
        if (userId == null || userId <= 0) {
            throw new BusinessException("userId 不合法");
        }
        return brandCrawlTaskMapper.selectList(new LambdaQueryWrapper<BrandCrawlTaskEntity>()
                .eq(BrandCrawlTaskEntity::getUserId, userId)
                .orderByDesc(BrandCrawlTaskEntity::getId)
                .last("limit 100"));
    }

    private List<BrandSourceFileDto> normalizeFiles(List<BrandSourceFileDto> files) {
        List<BrandSourceFileDto> result = new ArrayList<>();
        if (files == null) {
            return result;
        }
        for (BrandSourceFileDto file : files) {
            if (file == null || file.getFileUrl() == null || file.getFileUrl().isBlank()) {
                continue;
            }
            BrandSourceFileDto item = new BrandSourceFileDto();
            item.setFileUrl(file.getFileUrl().trim());
            item.setOriginalFilename(blankToNull(file.getOriginalFilename()));
            item.setRelativePath(blankToNull(file.getRelativePath()));
            result.add(item);
        }
        return result;
    }

    private List<BrandSourceFileDto> normalizePathFiles(List<String> paths) {
        List<BrandSourceFileDto> files = new ArrayList<>();
        if (paths == null) {
            return files;
        }
        for (String path : paths) {
            if (path == null || path.isBlank()) {
                continue;
            }
            String trimmed = path.trim();
            File file = new File(trimmed);
            if (!file.isFile()) {
                continue;
            }
            BrandSourceFileDto item = new BrandSourceFileDto();
            item.setFileUrl(trimmed);
            item.setOriginalFilename(file.getName());
            item.setRelativePath(null);
            files.add(item);
        }
        return files;
    }

    private List<BrandSourceFileDto> parseSourceFiles(String rawJson) {
        if (rawJson == null || rawJson.isBlank()) {
            return List.of();
        }
        try {
            return objectMapper.readValue(rawJson, new TypeReference<List<BrandSourceFileDto>>() {
            });
        } catch (Exception ex) {
            try {
                List<String> paths = objectMapper.readValue(rawJson, new TypeReference<List<String>>() {
                });
                return normalizePathFiles(paths);
            } catch (Exception innerEx) {
                throw new BusinessException("解析任务文件失败");
            }
        }
    }

    private BrandTaskItemVo toTaskItem(BrandCrawlTaskEntity entity) {
        BrandTaskItemVo vo = new BrandTaskItemVo();
        vo.setId(entity.getId());
        vo.setFilePaths(parseJsonList(entity.getFilePaths()));
        vo.setDesc(blankToNull(entity.getDesc()));
        vo.setStrategy(blankToDefault(entity.getStrategy(), "Terms"));
        vo.setStatus(blankToDefault(entity.getStatus(), STATUS_PENDING));
        vo.setResultPaths(parseJsonValue(entity.getResultPaths()));
        vo.setErrorMessage(blankToNull(entity.getErrorMessage()));
        vo.setProgressCurrent(defaultInteger(entity.getProgressCurrent()));
        vo.setProgressTotal(defaultInteger(entity.getProgressTotal()));
        vo.setCreatedAt(formatTime(entity.getCreatedAt()));
        vo.setUpdatedAt(formatTime(entity.getUpdatedAt()));
        return vo;
    }

    private LegacyBrandTaskItemVo toLegacyTaskItem(BrandCrawlTaskEntity entity) {
        LegacyBrandTaskItemVo vo = new LegacyBrandTaskItemVo();
        vo.setId(entity.getId());
        vo.setFile_paths(parseJsonList(entity.getFilePaths()));
        vo.setDesc(blankToNull(entity.getDesc()));
        vo.setStrategy(blankToDefault(entity.getStrategy(), "Terms"));
        vo.setStatus(blankToDefault(entity.getStatus(), STATUS_PENDING));
        vo.setResult_paths(parseJsonValue(entity.getResultPaths()));
        vo.setError_message(blankToNull(entity.getErrorMessage()));
        vo.setProgress_current(defaultInteger(entity.getProgressCurrent()));
        vo.setProgress_total(defaultInteger(entity.getProgressTotal()));
        vo.setCreated_at(formatTime(entity.getCreatedAt()));
        vo.setUpdated_at(formatTime(entity.getUpdatedAt()));
        return vo;
    }

    private List<Object> parseJsonList(String rawJson) {
        Object value = parseJsonValue(rawJson);
        if (value instanceof List<?> list) {
            return new ArrayList<>(list);
        }
        return new ArrayList<>();
    }

    private Object parseJsonValue(String rawJson) {
        if (rawJson == null || rawJson.isBlank()) {
            return null;
        }
        try {
            return objectMapper.readValue(rawJson, Object.class);
        } catch (Exception ex) {
            return null;
        }
    }

    private String formatTime(LocalDateTime time) {
        return time == null ? "" : DATETIME_FORMATTER.format(time);
    }

    private String normalizeStrategy(String strategy) {
        String value = blankToDefault(strategy, "Terms");
        return "Simple".equalsIgnoreCase(value) ? "Simple" : "Terms";
    }

    private int normalizeTaskType(Integer taskType, int defaultValue) {
        if (taskType == null) {
            return defaultValue;
        }
        return Objects.equals(taskType, 1) ? 1 : 2;
    }

    private void ensureNoDuplicateResultFiles(List<BrandCrawlResultFileDto> resultFiles) {
        Set<String> seen = new LinkedHashSet<>();
        for (BrandCrawlResultFileDto resultFile : resultFiles) {
            String fileUrl = blankToNull(resultFile.getFileUrl());
            if (fileUrl == null) {
                throw new BusinessException("fileUrl 不能为空");
            }
            if (!seen.add(fileUrl)) {
                throw new BusinessException("存在重复 fileUrl: " + fileUrl);
            }
        }
    }

    private void updateTaskProgress(Long taskId, int finishedCount, int totalCount) {
        brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
                .eq(BrandCrawlTaskEntity::getId, taskId)
                .ne(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED)
                .set(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING)
                .set(BrandCrawlTaskEntity::getProgressCurrent, finishedCount)
                .set(BrandCrawlTaskEntity::getProgressTotal, totalCount)
                .set(BrandCrawlTaskEntity::getUpdatedAt, LocalDateTime.now())
                .set(BrandCrawlTaskEntity::getErrorMessage, null));
    }

    private void updateResultDrivenProgress(Long taskId,
                                            List<BrandSourceFileDto> sourceFiles,
                                            Map<String, BrandSourceFileDto> sourceByUrl,
                                            Map<String, List<BrandCrawlResultFileDto>> groupedResults,
                                            int finishedCount,
                                            int totalCount) {
        if (groupedResults.isEmpty()) {
            return;
        }
        String currentFileUrl = null;
        List<BrandCrawlResultFileDto> currentChunks = null;
        int currentFileIndex = 0;
        for (int i = sourceFiles.size() - 1; i >= 0; i--) {
            BrandSourceFileDto sourceFile = sourceFiles.get(i);
            List<BrandCrawlResultFileDto> chunks = groupedResults.get(sourceFile.getFileUrl());
            if (chunks == null || chunks.isEmpty()) {
                continue;
            }
            currentFileUrl = sourceFile.getFileUrl();
            currentChunks = chunks;
            currentFileIndex = i + 1;
            break;
        }
        if (currentFileUrl == null || currentChunks == null) {
            return;
        }
        BrandSourceFileDto sourceFile = sourceByUrl.get(currentFileUrl);
        String fileName = sourceFile == null ? "" : blankToDefault(sourceFile.getOriginalFilename(), sourceFile.getFileUrl());
        int currentLine = countProcessedLines(currentChunks);
        int totalLines = Math.max(inferTotalLines(currentChunks), currentLine);
        brandTaskProgressCacheService.saveProgressFromResult(taskId,
                currentFileUrl,
                currentFileIndex,
                totalCount,
                fileName,
                currentLine,
                totalLines,
                finishedCount);
    }

    private int countProcessedLines(List<BrandCrawlResultFileDto> chunks) {
        int currentLine = 0;
        for (BrandCrawlResultFileDto chunk : chunks) {
            if (chunk.getKeptRows() != null) {
                currentLine += chunk.getKeptRows().size();
            }
            if (chunk.getInvalidBrands() != null) {
                currentLine += chunk.getInvalidBrands().size();
            }
            if (chunk.getQueryFailedBrands() != null) {
                currentLine += chunk.getQueryFailedBrands().size();
            }
        }
        return currentLine;
    }

    private int inferTotalLines(List<BrandCrawlResultFileDto> chunks) {
        if (chunks == null || chunks.isEmpty()) {
            return 0;
        }
        BrandCrawlResultFileDto last = chunks.get(chunks.size() - 1);
        if (last.getTotalLines() != null && last.getTotalLines() > 0) {
            return last.getTotalLines();
        }
        if (last.getChunkTotal() == null || last.getChunkTotal() <= 0) {
            return countProcessedLines(chunks);
        }
        int processed = countProcessedLines(chunks);
        if (Objects.equals(chunks.size(), last.getChunkTotal())) {
            return processed;
        }
        int averagePerChunk = Math.max(processed / Math.max(chunks.size(), 1), 1);
        return averagePerChunk * last.getChunkTotal();
    }

    private String buildDesc(String strategy, List<BrandSourceFileDto> files) {
        String joined = files.stream()
                .map(file -> blankToDefault(file.getOriginalFilename(), file.getFileUrl()))
                .filter(Objects::nonNull)
                .map(String::trim)
                .filter(name -> !name.isEmpty())
                .limit(20)
                .reduce((left, right) -> left + ", " + right)
                .orElse("");
        String desc = "[" + strategy + "] " + joined;
        return desc.length() > 500 ? desc.substring(0, 500) : desc;
    }

    private BrandCrawlTaskEntity requireTask(Long taskId) {
        BrandCrawlTaskEntity entity = brandCrawlTaskMapper.selectById(taskId);
        if (entity == null) {
            throw new BusinessException("任务不存在");
        }
        return entity;
    }

    private BrandCrawlTaskEntity requireTask(Long taskId, Long userId) {
        if (userId == null || userId <= 0) {
            throw new BusinessException("userId 不合法");
        }
        BrandCrawlTaskEntity entity = brandCrawlTaskMapper.selectById(taskId);
        if (entity == null || !userId.equals(entity.getUserId())) {
            throw new BusinessException("任务不存在");
        }
        return entity;
    }

    private File resolveSourceFile(BrandSourceFileDto sourceFile) {
        String fileUrl = sourceFile.getFileUrl();
        if (fileUrl == null || fileUrl.isBlank()) {
            throw new BusinessException("fileUrl 不能为空");
        }
        File directFile = new File(fileUrl);
        if (directFile.isFile()) {
            return directFile;
        }
        return downloadSourceFile(fileUrl);
    }

    private File downloadSourceFile(String fileUrl) {
        try {
            URI uri = URI.create(fileUrl);
            String filename = FileUtil.getName(uri.getPath());
            if (filename == null || filename.isBlank()) {
                filename = "brand-source.xlsx";
            }
            String suffix = FileUtil.extName(filename);
            File downloadDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "brand-source-download"));
            File tempFile = Files.createTempFile(downloadDir.toPath(), "brand_", suffix.isBlank() ? "" : "." + suffix).toFile();
            try (InputStream inputStream = uri.toURL().openStream()) {
                FileUtil.writeFromStream(inputStream, tempFile);
            }
            return tempFile;
        } catch (Exception ex) {
            throw new BusinessException("下载源文件失败");
        }
    }

    private String resolveOriginalFilename(BrandSourceFileDto sourceFile, File inputFile) {
        return blankToDefault(sourceFile.getOriginalFilename(), inputFile.getName());
    }

    private ParsedBrandFile parseBrandFile(File inputFile) {
        DataFormatter formatter = new DataFormatter();
        try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = WorkbookFactory.create(fis)) {
            Sheet sheet = workbook.getSheetAt(0);
            Row headerRow = sheet.getRow(0);
            if (headerRow == null) {
                throw new BusinessException("Excel 表头为空");
            }
            List<String> columns = extractHeaders(headerRow, formatter);
            if (columns.isEmpty()) {
                throw new BusinessException("未读取到有效表头");
            }
            Map<String, Integer> headerIndexes = buildHeaderIndexes(headerRow, formatter, columns);
            List<Map<String, Object>> rows = new ArrayList<>();
            Set<String> uniqueBrands = new LinkedHashSet<>();
            for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
                Row row = sheet.getRow(rowNum);
                if (row == null) {
                    continue;
                }
                Map<String, Object> rowData = new LinkedHashMap<>();
                for (String column : columns) {
                    Integer index = headerIndexes.get(column);
                    String value = index == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(index)));
                    rowData.put(column, value);
                }
                String brand = normalizeCellText(Objects.toString(rowData.getOrDefault("\u54c1\u724c", ""), ""));
                if (brand.isBlank()) {
                    rowData.put("__rowIndex", rowNum + 1);
                    rows.add(rowData);
                    continue;
                }
                if (uniqueBrands.add(brand)) {
                    rowData.put("__rowIndex", rowNum + 1);
                    rows.add(rowData);
                }
            }
            return new ParsedBrandFile(sheet.getSheetName(), columns, rows, new ArrayList<>(uniqueBrands));
        } catch (IOException ex) {
            throw new BusinessException("读取 Excel 失败");
        }
    }

    private List<String> extractHeaders(Row headerRow, DataFormatter formatter) {
        List<String> headers = new ArrayList<>();
        Set<String> seen = new LinkedHashSet<>();
        for (int i = 0; i < headerRow.getLastCellNum(); i++) {
            Cell cell = headerRow.getCell(i);
            String value = normalizeHeaderValue(cell == null ? null : formatter.formatCellValue(cell));
            if (value.isBlank() || seen.contains(value)) {
                continue;
            }
            seen.add(value);
            headers.add(value);
            if ("缩略图地址8".equals(value)) {
                break;
            }
        }
        return headers;
    }

    private Map<String, Integer> buildHeaderIndexes(Row headerRow, DataFormatter formatter, List<String> columns) {
        Map<String, Integer> headerIndexes = new LinkedHashMap<>();
        Set<String> allowed = new LinkedHashSet<>(columns);
        for (int i = 0; i < headerRow.getLastCellNum(); i++) {
            Cell cell = headerRow.getCell(i);
            String value = normalizeHeaderValue(cell == null ? null : formatter.formatCellValue(cell));
            if (value.isBlank() || headerIndexes.containsKey(value) || !allowed.contains(value)) {
                continue;
            }
            headerIndexes.put(value, i);
            if ("缩略图地址8".equals(value)) {
                break;
            }
        }
        return headerIndexes;
    }

    private String normalizeHeaderValue(String value) {
        String normalized = normalizeCellText(value);
        if (normalized.isBlank()) {
            return "";
        }
        String[] parts = normalized.split("idASIN国家状态价格变体数量", 2);
        String trimmed = parts[0].trim();
        return trimmed.isEmpty() ? normalized : trimmed;
    }

    private String normalizeCellText(String value) {
        if (value == null) {
            return "";
        }
        return value.replace("\ufeff", "")
                .replace("\u3000", " ")
                .replace("\r\n", " ")
                .replace("\r", " ")
                .replace("\n", " ")
                .replace("\t", " ")
                .trim()
                .replaceAll("\\s+", " ");
    }

    private void writeBrandWorkbook(File outputFile,
                                    String strategy,
                                    BrandParsedFileCacheDto cachedFile,
                                    BrandCrawlResultFileDto resultFile) throws IOException {
        String actualStrategy = normalizeStrategy(strategy);
        try (XSSFWorkbook workbook = new XSSFWorkbook()) {
            String mainSheetName = blankToDefault(cachedFile.getSheetName(), "Sheet1");
            var mainSheet = workbook.createSheet(mainSheetName);
            var headerRow = mainSheet.createRow(0);
            List<String> columns = cachedFile.getColumns() == null ? List.of() : cachedFile.getColumns();
            for (int i = 0; i < columns.size(); i++) {
                headerRow.createCell(i).setCellValue(columns.get(i));
            }

            Set<String> invalidBrandSet = normalizeBrandSetFromInvalids(resultFile.getInvalidBrands());
            List<Map<String, Object>> sourceRows = cachedFile.getRows() == null ? List.of() : cachedFile.getRows();
            int writeRowIndex = 1;
            for (Map<String, Object> rowData : sourceRows) {
                String brand = normalizeCellText(Objects.toString(rowData.getOrDefault("品牌", ""), ""));
                if (!brand.isBlank() && invalidBrandSet.contains(brand)) {
                    continue;
                }
                var row = mainSheet.createRow(writeRowIndex++);
                for (int colIndex = 0; colIndex < columns.size(); colIndex++) {
                    String column = columns.get(colIndex);
                    row.createCell(colIndex).setCellValue(Objects.toString(rowData.getOrDefault(column, ""), ""));
                }
            }

            var invalidSheet = workbook.createSheet("不符合品牌");
            var invalidHeader = invalidSheet.createRow(0);
            invalidHeader.createCell(0).setCellValue("品牌");
            invalidHeader.createCell(1).setCellValue("国家");
            invalidHeader.createCell(2).setCellValue("状态");
            List<BrandInvalidBrandDto> invalidBrands = resultFile.getInvalidBrands() == null ? List.of() : resultFile.getInvalidBrands();
            for (int i = 0; i < invalidBrands.size(); i++) {
                var row = invalidSheet.createRow(i + 1);
                BrandInvalidBrandDto item = invalidBrands.get(i);
                row.createCell(0).setCellValue(blankToDefault(item.getBrand(), ""));
                row.createCell(1).setCellValue(blankToDefault(item.getCountry(), ""));
                row.createCell(2).setCellValue(blankToDefault(item.getStatus(), ""));
            }

            var queryFailedSheet = workbook.createSheet("查询失败品牌");
            var queryHeader = queryFailedSheet.createRow(0);
            queryHeader.createCell(0).setCellValue("品牌");
            queryHeader.createCell(1).setCellValue("时间");
            List<String> queryFailedBrands = resultFile.getQueryFailedBrands() == null ? List.of() : resultFile.getQueryFailedBrands();
            String nowText = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yy-MM-dd HH:mm:ss"));
            for (int i = 0; i < queryFailedBrands.size(); i++) {
                var row = queryFailedSheet.createRow(i + 1);
                row.createCell(0).setCellValue(blankToDefault(queryFailedBrands.get(i), ""));
                row.createCell(1).setCellValue(nowText);
            }

            try (FileOutputStream outputStream = new FileOutputStream(outputFile)) {
                workbook.write(outputStream);
            }
        }
    }

    private Set<String> normalizeBrandSetFromInvalids(List<BrandInvalidBrandDto> invalidBrands) {
        Set<String> result = new LinkedHashSet<>();
        if (invalidBrands == null) {
            return result;
        }
        for (BrandInvalidBrandDto invalidBrand : invalidBrands) {
            if (invalidBrand == null) {
                continue;
            }
            String normalized = normalizeCellText(invalidBrand.getBrand());
            if (!normalized.isBlank()) {
                result.add(normalized);
            }
        }
        return result;
    }

    private Set<String> normalizeBrandSet(List<String> brands) {
        Set<String> result = new LinkedHashSet<>();
        if (brands == null) {
            return result;
        }
        for (String brand : brands) {
            String normalized = normalizeCellText(brand);
            if (!normalized.isBlank()) {
                result.add(normalized);
            }
        }
        return result;
    }

    private Map<String, Object> buildAndUploadResult(Long taskId, List<OutputEntry> entries) throws IOException {
        if (entries.isEmpty()) {
            throw new BusinessException("没有可上传的结果文件");
        }
        List<String> fullUrls = new ArrayList<>();
        for (OutputEntry entry : entries) {
            // 存储完整公开地址，下载时会自动通过 resolveObjectKey 提取并重新签名（见 OssStorageService.resolveObjectKey）
            String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), "BRAND");
            fullUrls.add(ossStorageService.getPublicUrl(objectKey));
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("urls", fullUrls);
        File zipFile = packageAsZip(taskId, entries);
        String zipObjectKey = ossStorageService.uploadResultFile(zipFile, "BRAND");
        result.put("zip_url", ossStorageService.getPublicUrl(zipObjectKey));
        return result;
    }

    private File packageAsZip(Long taskId, List<OutputEntry> entries) throws IOException {
        File zipDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "brand-result", String.valueOf(taskId)));
        File zipFile = buildNamedOutputFile(zipDir, buildArchiveFilename(entries));
        Set<String> zipEntryNames = new LinkedHashSet<>();
        try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
            byte[] buffer = new byte[8192];
            for (OutputEntry entry : entries) {
                writeZipEntry(zos, buffer, entry.sourceFile(), buildSourceZipEntry(entry.zipSourceFilename()), zipEntryNames);
                writeZipEntry(zos, buffer, entry.resultFile(), buildResultZipEntry(entry.zipResultFilename()), zipEntryNames);
            }
        }
        return zipFile;
    }

    private File buildNamedOutputFile(File outputDir, String filename) {
        File candidate = FileUtil.file(outputDir, filename);
        if (!candidate.exists()) {
            return candidate;
        }
        String mainName = FileUtil.mainName(filename);
        String extName = FileUtil.extName(filename);
        int index = 2;
        while (true) {
            String nextFilename = extName.isEmpty() ? mainName + "_" + index : mainName + "_" + index + "." + extName;
            File nextFile = FileUtil.file(outputDir, nextFilename);
            if (!nextFile.exists()) {
                return nextFile;
            }
            index++;
        }
    }

    private void writeZipEntry(ZipOutputStream zos,
                               byte[] buffer,
                               File file,
                               String entryName,
                               Set<String> zipEntryNames) throws IOException {
        if (!zipEntryNames.add(entryName)) {
            throw new BusinessException("压缩包中存在重复文件名: " + entryName);
        }
        zos.putNextEntry(new ZipEntry(entryName));
        try (FileInputStream inputStream = new FileInputStream(file)) {
            int len;
            while ((len = inputStream.read(buffer)) > 0) {
                zos.write(buffer, 0, len);
            }
        }
        zos.closeEntry();
    }

    private String buildSourceZipEntry(String sourceFilename) {
        return "源文件/" + normalizeZipFilename(sourceFilename);
    }

    private String buildResultZipEntry(String resultFilename) {
        return "结果文件/" + normalizeZipFilename(resultFilename);
    }

    private String normalizeZipFilename(String filename) {
        String normalized = blankToDefault(filename, "brand.xlsx").replace('\\', '/');
        int slashIndex = normalized.lastIndexOf('/');
        if (slashIndex >= 0) {
            normalized = normalized.substring(slashIndex + 1);
        }
        return normalized;
    }

    private String buildResultFilename(String originalFilename) {
        return blankToDefault(originalFilename, "brand.xlsx");
    }

    private String buildArchiveFilename(List<OutputEntry> entries) {
        if (entries == null || entries.isEmpty()) {
            return "brand.zip";
        }
        String sourceFilename = blankToDefault(entries.get(0).zipSourceFilename(), "brand.xlsx");
        return FileUtil.mainName(sourceFilename) + ".zip";
    }

    private Integer defaultInteger(Integer value) {
        return value == null ? 0 : value;
    }

    private String blankToDefault(String value, String defaultValue) {
        return value == null || value.isBlank() ? defaultValue : value;
    }

    private String blankToNull(String value) {
        return value == null || value.isBlank() ? null : value.trim();
    }

    private LegacyBrandLineProgressVo buildLineProgress(Long taskId) {
        LegacyBrandLineProgressVo vo = new LegacyBrandLineProgressVo();
        Map<Object, Object> progress = brandTaskProgressCacheService.getProgress(taskId);
        if (progress.isEmpty()) {
            vo.setHas_progress(false);
            return vo;
        }
        LegacyBrandLineProgressInfoVo info = new LegacyBrandLineProgressInfoVo();
        info.setFile_index(parseInteger(progress.get("file_index")));
        info.setFile_total(parseInteger(progress.get("file_total")));
        info.setFile_name(Objects.toString(progress.getOrDefault("file_name", ""), ""));
        info.setCurrent_line(parseInteger(progress.get("current_line")));
        info.setTotal_lines(parseInteger(progress.get("total_lines")));
        info.setPhase(Objects.toString(progress.getOrDefault("phase", ""), ""));
        vo.setHas_progress(true);
        vo.setInfo(info);
        return vo;
    }

    private int countCompletedFiles(Map<String, List<BrandCrawlResultFileDto>> groupedResults) {
        int count = 0;
        for (List<BrandCrawlResultFileDto> chunks : groupedResults.values()) {
            if (!chunks.isEmpty() && Objects.equals(chunks.size(), chunks.get(0).getChunkTotal())) {
                count++;
            }
        }
        return count;
    }

    private BrandCrawlResultFileDto mergeChunks(List<BrandCrawlResultFileDto> chunks) {
        if (chunks == null || chunks.isEmpty()) {
            throw new BusinessException("结果分片为空");
        }
        BrandCrawlResultFileDto first = chunks.get(0);
        if (!Objects.equals(chunks.size(), first.getChunkTotal())) {
            throw new BusinessException("文件结果分片未完成");
        }
        BrandCrawlResultFileDto merged = new BrandCrawlResultFileDto();
        merged.setFileUrl(first.getFileUrl());
        merged.setOriginalFilename(first.getOriginalFilename());
        merged.setRelativePath(first.getRelativePath());
        merged.setMainSheetName(first.getMainSheetName());
        merged.setChunkIndex(first.getChunkIndex());
        merged.setChunkTotal(first.getChunkTotal());
        merged.setTotalLines(first.getTotalLines());
        List<String> keptRows = new ArrayList<>();
        List<BrandInvalidBrandDto> invalidBrands = new ArrayList<>();
        List<String> queryFailedBrands = new ArrayList<>();
        for (BrandCrawlResultFileDto chunk : chunks) {
            if (chunk.getKeptRows() != null) {
                keptRows.addAll(chunk.getKeptRows());
            }
            if (chunk.getInvalidBrands() != null) {
                invalidBrands.addAll(chunk.getInvalidBrands());
            }
            if (chunk.getQueryFailedBrands() != null) {
                queryFailedBrands.addAll(chunk.getQueryFailedBrands());
            }
        }
        merged.setKeptRows(keptRows);
        merged.setInvalidBrands(invalidBrands);
        merged.setQueryFailedBrands(queryFailedBrands);
        return merged;
    }

    private Integer parseInteger(Object value) {
        if (value == null) {
            return 0;
        }
        try {
            return Integer.parseInt(String.valueOf(value));
        } catch (Exception ex) {
            return 0;
        }
    }

    @Transactional
    @org.springframework.scheduling.annotation.Scheduled(cron = "${aiimage.brand-progress.stale-check-cron:0 */2 * * * *}")
    public void failStaleRunningTasks() {
        LocalDateTime threshold = LocalDateTime.now().minusMinutes(brandProgressProperties.getHeartbeatTimeoutMinutes());
        List<BrandCrawlTaskEntity> runningTasks = brandCrawlTaskMapper.selectList(new LambdaQueryWrapper<BrandCrawlTaskEntity>()
                .eq(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING)
                .lt(BrandCrawlTaskEntity::getUpdatedAt, threshold));
        for (BrandCrawlTaskEntity task : runningTasks) {
            Map<Object, Object> progress = brandTaskProgressCacheService.getProgress(task.getId());
            if (progress.isEmpty()) {
                continue;
            }
            long lastHeartbeatAt = 0L;
            try {
                lastHeartbeatAt = Long.parseLong(String.valueOf(progress.getOrDefault("last_heartbeat_at", "0")));
            } catch (Exception ignored) {
            }
            if (lastHeartbeatAt <= 0) {
                continue;
            }
            LocalDateTime lastHeartbeat = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastHeartbeatAt), ZoneId.systemDefault());
            if (lastHeartbeat.isAfter(threshold)) {
                continue;
            }
            brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
                    .eq(BrandCrawlTaskEntity::getId, task.getId())
                    .eq(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING)
                    .set(BrandCrawlTaskEntity::getStatus, STATUS_FAILED)
                    .set(BrandCrawlTaskEntity::getErrorMessage, "前端长时间无响应，任务已自动失败"));
            brandTaskProgressCacheService.markFailed(task.getId(), "前端长时间无响应，任务已自动失败");
        }
    }

    private record ParsedBrandFile(String sheetName, List<String> columns, List<Map<String, Object>> rows,
                                   List<String> uniqueBrands) {
    }

    private record OutputEntry(File sourceFile, String zipSourceFilename, File resultFile, String zipResultFilename) {
    }
}
