package tech.easyflow.ai.node;

import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * {@link DocNodeFileContentExtractor} 单元测试。
 *
 * @author Codex
 * @since 2026-04-14
 */
public class DocNodeFileContentExtractorTest {

    /**
     * 验证 PDF 文件会走统一文档解析桥接服务。
     */
    @Test
    public void shouldUseDocumentBridgeForPdf() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FakeFileStorageService(),
            new FakeReaderManager("ignored")
        );

        String content = extractor.extract(buildFileValue("demo.pdf", "/files/demo.pdf", "application/pdf"));

        Assert.assertEquals("# parsed", content);
        Assert.assertNotNull(bridgeService.lastSource);
        Assert.assertEquals("demo.pdf", bridgeService.lastSource.getFileName());
    }

    /**
     * 验证 DOCX 文件会走统一文档解析桥接服务。
     */
    @Test
    public void shouldUseDocumentBridgeForDocx() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FakeFileStorageService(),
            new FakeReaderManager("ignored")
        );

        String content = extractor.extract(buildFileValue("demo.docx", "/files/demo.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"));

        Assert.assertEquals("# parsed", content);
        Assert.assertNotNull(bridgeService.lastSource);
        Assert.assertEquals("demo.docx", bridgeService.lastSource.getFileName());
    }

    /**
     * 验证 PPTX 文件会走统一文档解析桥接服务。
     */
    @Test
    public void shouldUseDocumentBridgeForPptx() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FakeFileStorageService(),
            new FakeReaderManager("ignored")
        );

        String content = extractor.extract(buildFileValue(
            "slides.pptx",
            "/files/slides.pptx",
            "application/vnd.openxmlformats-officedocument.presentationml.presentation"
        ));

        Assert.assertEquals("# parsed", content);
        Assert.assertNotNull(bridgeService.lastSource);
        Assert.assertEquals("slides.pptx", bridgeService.lastSource.getFileName());
    }

    /**
     * 验证 XLSX 文件会走统一文档解析桥接服务。
     */
    @Test
    public void shouldUseDocumentBridgeForXlsx() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FakeFileStorageService(),
            new FakeReaderManager("ignored")
        );

        String content = extractor.extract(buildFileValue(
            "table.xlsx",
            "/files/table.xlsx",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
        ));

        Assert.assertEquals("# parsed", content);
        Assert.assertNotNull(bridgeService.lastSource);
        Assert.assertEquals("table.xlsx", bridgeService.lastSource.getFileName());
    }

    /**
     * 验证非桥接类型文件会继续走默认读取器。
     */
    @Test
    public void shouldUseDefaultReaderForUnsupportedType() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FakeFileStorageService(),
            new FakeReaderManager("plain text")
        );

        String content = extractor.extract(buildFileValue("note.txt", "/files/note.txt", "text/plain"));

        Assert.assertEquals("plain text", content);
        Assert.assertNull(bridgeService.lastSource);
    }

    /**
     * 验证缺少 filePath 时会抛出明确异常。
     */
    @Test
    public void shouldRejectMissingFilePath() {
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            new RecordingDocumentParseBridgeService(),
            new FakeFileStorageService(),
            new FakeReaderManager("plain text")
        );

        try {
            extractor.extract(buildFileValue("demo.pdf", null, "application/pdf"));
            Assert.fail("expected BusinessException");
        } catch (BusinessException e) {
            Assert.assertEquals("文件输入缺少 filePath", e.getMessage());
        }
    }

    /**
     * 验证桥接解析结果为空时不会回退旧读取链路。
     */
    @Test
    public void shouldFailWhenBridgeParseResultIsEmpty() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        bridgeService.response.setPreferredText(null);
        bridgeService.response.setMarkdown(null);
        bridgeService.response.setPlainText(null);
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FakeFileStorageService(),
            new FakeReaderManager("pdf fallback")
        );

        try {
            extractor.extract(buildFileValue("demo.pdf", "/files/demo.pdf", "application/pdf"));
            Assert.fail("expected BusinessException");
        } catch (BusinessException e) {
            Assert.assertEquals("文档解析结果为空", e.getMessage());
        }
    }

    /**
     * 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址。
     */
    @Test
    public void shouldRejectLoopbackRemoteUrlForUnsupportedType() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FailingFileStorageService(),
            new ReadingReaderManager()
        );

        RuntimeException exception = Assert.assertThrows(
                RuntimeException.class,
                () -> extractor.extract(buildFileValue(
                        "note.txt",
                        "http://127.0.0.1:39000/note.txt",
                        "text/plain")));

        Throwable cause = exception;
        while (cause != null
                && !(cause instanceof java.net.UnknownHostException)) {
            cause = cause.getCause();
        }
        Assert.assertNotNull(cause);
        Assert.assertNull(bridgeService.lastSource);
    }

    /**
     * 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。
     *
     * @throws IOException 测试流配置失败时抛出
     */
    @Test
    public void shouldReadVerifiedManagedUploadForUnsupportedType() throws IOException {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        WorkflowApiUploadedFileReader uploadedFileReader =
                Mockito.mock(WorkflowApiUploadedFileReader.class);
        String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/"
                + "workflow-api-upload/0123456789abcdef0123456789abcdef/note.txt";
        byte[] body = "managed text".getBytes(StandardCharsets.UTF_8);
        Mockito.when(uploadedFileReader.isManagedPathCandidate(fileUrl))
                .thenReturn(true);
        Mockito.when(uploadedFileReader.openVerified(fileUrl))
                .thenReturn(Optional.of(new ByteArrayInputStream(body)));
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
                bridgeService,
                new FailingFileStorageService(),
                new ReadingReaderManager(),
                uploadedFileReader);

        String content = extractor.extract(buildFileValue(
                "note.txt",
                fileUrl,
                "text/plain"));

        Assert.assertEquals("managed text", content);
        Assert.assertNull(bridgeService.lastSource);
    }

    /**
     * 验证多文件输入会按顺序返回逐文件结果。
     */
    @Test
    public void shouldExtractDocumentsForMultipleFiles() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FakeFileStorageService(),
            new FakeReaderManager("plain text")
        );

        List<DocNodeFileContentExtractor.DocExtractedDocument> documents = extractor.extractDocuments(Arrays.asList(
            buildFileValue("demo.pdf", "/files/demo.pdf", "application/pdf"),
            buildFileValue("note.txt", "/files/note.txt", "text/plain")
        ));

        Assert.assertEquals(2, documents.size());
        Assert.assertEquals("demo.pdf", documents.get(0).getFileName());
        Assert.assertEquals("# parsed", documents.get(0).getContent());
        Assert.assertEquals("note.txt", documents.get(1).getFileName());
        Assert.assertEquals("plain text", documents.get(1).getContent());
    }

    /**
     * 验证多文件中任一文件失败时会暴露文件名。
     */
    @Test
    public void shouldExposeFileNameWhenMultipleDocumentsFail() {
        RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
        bridgeService.response.setPreferredText(null);
        bridgeService.response.setMarkdown(null);
        bridgeService.response.setPlainText(null);
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            bridgeService,
            new FakeFileStorageService(),
            new FakeReaderManager("plain text")
        );

        try {
            extractor.extractDocuments(Arrays.asList(
                buildFileValue("broken.pdf", "/files/broken.pdf", "application/pdf"),
                buildFileValue("note.txt", "/files/note.txt", "text/plain")
            ));
            Assert.fail("expected BusinessException");
        } catch (BusinessException e) {
            Assert.assertEquals("文件解析失败(broken.pdf): 文档解析结果为空", e.getMessage());
        }
    }

    /**
     * 验证文档节点允许 100MB 边界值，并拒绝超过边界的文件。
     */
    @Test
    public void shouldEnforceHundredMbSingleFileLimit() {
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            new RecordingDocumentParseBridgeService(),
            new FakeFileStorageService(),
            new FakeReaderManager("plain text")
        );
        Map<String, Object> accepted = buildFileValue(
            "accepted.pdf",
            "/files/accepted.pdf",
            "application/pdf"
        );
        accepted.put("size", 100L * 1024L * 1024L);
        Assert.assertEquals(1, extractor.toDocumentSourceRefs(accepted).size());

        Map<String, Object> oversized = buildFileValue(
            "oversized.pdf",
            "/files/oversized.pdf",
            "application/pdf"
        );
        oversized.put("size", 100L * 1024L * 1024L + 1L);
        try {
            extractor.toDocumentSourceRefs(oversized);
            Assert.fail("expected BusinessException");
        } catch (BusinessException exception) {
            Assert.assertEquals("单个文件不能超过 100MB: oversized.pdf", exception.getMessage());
        }
    }

    /**
     * 验证文档节点允许总大小 100MB 边界值，并拒绝超过边界的文件列表。
     */
    @Test
    public void shouldEnforceHundredMbTotalFileLimit() {
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            new RecordingDocumentParseBridgeService(),
            new FakeFileStorageService(),
            new FakeReaderManager("plain text")
        );
        Map<String, Object> first = buildFileValue(
            "first.pdf",
            "/files/first.pdf",
            "application/pdf"
        );
        first.put("size", 50L * 1024L * 1024L);
        Map<String, Object> second = buildFileValue(
            "second.pdf",
            "/files/second.pdf",
            "application/pdf"
        );
        second.put("size", 50L * 1024L * 1024L);
        Assert.assertEquals(2, extractor.toDocumentSourceRefs(List.of(first, second)).size());

        second.put("size", 50L * 1024L * 1024L + 1L);
        try {
            extractor.toDocumentSourceRefs(List.of(first, second));
            Assert.fail("expected BusinessException");
        } catch (BusinessException exception) {
            Assert.assertEquals("文件总大小不能超过 100MB", exception.getMessage());
        }
    }

    /**
     * 验证文件元数据偏小时仍按实际读取量拒绝超过 100MB 的内容。
     */
    @Test
    public void shouldEnforceActualStreamSizeLimit() {
        DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
            new RecordingDocumentParseBridgeService(),
            new OversizedFileStorageService(),
            new DrainingReaderManager()
        );
        Map<String, Object> input = buildFileValue(
            "oversized.txt",
            "/files/oversized.txt",
            "text/plain"
        );
        input.put("size", 1L);

        try {
            extractor.extract(input);
            Assert.fail("expected BusinessException");
        } catch (BusinessException exception) {
            Assert.assertEquals(
                "文件实际读取大小超过 100MB: oversized.txt",
                exception.getMessage());
        }
    }

    private Map<String, Object> buildFileValue(String fileName, String filePath, String contentType) {
        Map<String, Object> value = new HashMap<String, Object>();
        value.put("fileName", fileName);
        value.put("filePath", filePath);
        value.put("contentType", contentType);
        value.put("size", 16L);
        value.put("url", filePath);
        return value;
    }

    private static class RecordingDocumentParseBridgeService implements DocumentParseBridgeService {

        private final DocumentParsedResult response = new DocumentParsedResult();
        private DocumentSourceRef lastSource;

        private RecordingDocumentParseBridgeService() {
            response.setPreferredText("# parsed");
            response.setMarkdown("# parsed");
            response.setPlainText("parsed");
        }

        @Override
        public DocumentParsedResult parse(DocumentSourceRef source, tech.easyflow.ai.document.model.DocumentParseScenario scenario) {
            this.lastSource = source;
            return response;
        }

        @Override
        public DocumentParseTaskStatus submit(DocumentSourceRef source, tech.easyflow.ai.document.model.DocumentParseScenario scenario) {
            return new DocumentParseTaskStatus();
        }

        @Override
        public DocumentParseTaskStatus queryTask(String taskId) {
            return new DocumentParseTaskStatus();
        }

        @Override
        public DocumentParsedResult queryResult(String taskId) {
            return response;
        }

        @Override
        public DocumentParseTaskInfo queryTaskInfo(String taskId) {
            return new DocumentParseTaskInfo();
        }
    }

    private static class FakeFileStorageService implements FileStorageService {

        @Override
        public String save(org.springframework.web.multipart.MultipartFile file) {
            return null;
        }

        @Override
        public void delete(String path) {
        }

        @Override
        public String save(File file, String prePath) {
            return null;
        }

        @Override
        public InputStream readStream(String path) throws IOException {
            return new ByteArrayInputStream("doc".getBytes(StandardCharsets.UTF_8));
        }

        @Override
        public long getFileSize(String path) {
            return 3L;
        }
    }

    private static class FakeReaderManager extends ReaderManager {

        private final String content;

        private FakeReaderManager(String content) {
            this.content = content;
        }

        @Override
        public ReadDocService getReader() {
            return (fileName, is) -> content;
        }
    }

    private static class ReadingReaderManager extends ReaderManager {

        @Override
        public ReadDocService getReader() {
            return (fileName, is) -> {
                try {
                    return new String(is.readAllBytes(), StandardCharsets.UTF_8);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            };
        }
    }

    private static class DrainingReaderManager extends ReaderManager {

        @Override
        public ReadDocService getReader() {
            return (fileName, is) -> {
                byte[] buffer = new byte[8192];
                try {
                    while (is.read(buffer) >= 0) {
                        // 仅消费输入流，用于验证实际字节数保护，不保留大对象。
                    }
                    return "unreachable";
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            };
        }
    }

    private static class OversizedFileStorageService implements FileStorageService {

        @Override
        public String save(org.springframework.web.multipart.MultipartFile file) {
            return null;
        }

        @Override
        public void delete(String path) {
        }

        @Override
        public String save(File file, String prePath) {
            return null;
        }

        @Override
        public InputStream readStream(String path) {
            return new InputStream() {
                private long remaining = 100L * 1024L * 1024L + 1L;

                @Override
                public int read() {
                    if (remaining <= 0L) {
                        return -1;
                    }
                    remaining--;
                    return 1;
                }

                @Override
                public int read(byte[] buffer, int offset, int length) {
                    if (remaining <= 0L) {
                        return -1;
                    }
                    int count = (int) Math.min(remaining, length);
                    java.util.Arrays.fill(buffer, offset, offset + count, (byte) 1);
                    remaining -= count;
                    return count;
                }
            };
        }

        @Override
        public long getFileSize(String path) {
            // 模拟存储元数据不准确，实际流大小必须成为最终保护边界。
            return 1L;
        }
    }

    private static class FailingFileStorageService implements FileStorageService {

        @Override
        public String save(org.springframework.web.multipart.MultipartFile file) {
            return null;
        }

        @Override
        public void delete(String path) {
        }

        @Override
        public String save(File file, String prePath) {
            return null;
        }

        @Override
        public InputStream readStream(String path) throws IOException {
            throw new IOException("should not read remote url from storage");
        }

        @Override
        public long getFileSize(String path) {
            return 0L;
        }
    }
}
