package com.easyagents.rag.ingestion.chunk;

import com.easyagents.core.util.StringUtil;
import com.easyagents.rag.core.*;
import com.easyagents.rag.ingestion.model.AnalysisResult;
import com.easyagents.rag.ingestion.model.StrategyConfig;

import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RagSplitStrategyRegistry {

    private static final Pattern MARKDOWN_HEADING = Pattern.compile("^(#{1,6})\\s+(.*)$");
    private static final Pattern QUESTION_PREFIX = Pattern.compile("^(Q|QUESTION|问|问题|FAQ\\s*[0-9]+)\\s*[.:：-]\\s*(.+)$", Pattern.CASE_INSENSITIVE);
    private static final Pattern ANSWER_PREFIX = Pattern.compile("^(A|ANSWER|答|答案)\\s*[.:：-]\\s*(.+)$", Pattern.CASE_INSENSITIVE);

    public List<RagChunk> split(AnalysisResult analysisResult, StrategyConfig strategyConfig) {
        String strategyCode = strategyConfig.getStrategyCode();
        if (!StringUtil.hasText(strategyCode) || RagStrategyCodes.AUTO.equals(strategyCode)) {
            strategyCode = analysisResult.getRecommendedStrategyCode();
        }
        String normalizedContent = analysisResult.getNormalizedContent();
        List<RagChunk> chunks;
        if (RagStrategyCodes.MARKDOWN_SECTION.equals(strategyCode)) {
            chunks = buildMarkdownChunks(normalizedContent, strategyConfig);
        } else if (RagStrategyCodes.OUTLINE_SECTION.equals(strategyCode)) {
            chunks = buildOutlineChunks(normalizedContent, strategyConfig);
        } else if (RagStrategyCodes.QA_PAIR.equals(strategyCode)) {
            chunks = buildQaChunks(normalizedContent, strategyConfig);
        } else if (RagStrategyCodes.CUSTOM_REGEX.equals(strategyCode)) {
            chunks = buildRegexChunks(normalizedContent, strategyConfig);
        } else {
            chunks = buildParagraphChunks(normalizedContent, strategyConfig);
        }
        return postProcess(enforceBgeM3HardLimit(chunks));
    }

    private List<RagChunk> buildMarkdownChunks(String content, StrategyConfig strategyConfig) {
        List<LineSlice> lines = sliceLines(content);
        List<SectionChunk> sections = new ArrayList<SectionChunk>();
        Deque<HeadingLevel> stack = new ArrayDeque<HeadingLevel>();
        SectionChunk current = null;
        for (LineSlice lineSlice : lines) {
            String line = lineSlice.trimmedLine;
            Matcher matcher = MARKDOWN_HEADING.matcher(line);
            if (matcher.matches()) {
                if (current != null) {
                    sections.add(current);
                }
                int level = matcher.group(1).length();
                while (!stack.isEmpty() && stack.peekLast().level >= level) {
                    stack.removeLast();
                }
                stack.addLast(new HeadingLevel(level, matcher.group(2).trim()));
                current = new SectionChunk(copyPath(stack), matcher.group(2).trim());
                current.addLine(lineSlice);
            } else {
                if (current == null) {
                    current = new SectionChunk(Collections.singletonList("未命名段落"), "未命名段落");
                }
                current.addLine(lineSlice);
            }
        }
        if (current != null) {
            sections.add(current);
        }
        return finalizeSectionChunks(content, sections, strategyConfig);
    }

    private List<RagChunk> buildOutlineChunks(String content, StrategyConfig strategyConfig) {
        List<LineSlice> lines = sliceLines(content);
        List<SectionChunk> sections = new ArrayList<SectionChunk>();
        Deque<HeadingLevel> stack = new ArrayDeque<HeadingLevel>();
        SectionChunk current = null;
        for (LineSlice lineSlice : lines) {
            String line = lineSlice.trimmedLine;
            OutlineHeading heading = OutlineHeading.parse(line);
            if (heading != null) {
                if (current != null) {
                    sections.add(current);
                }
                while (!stack.isEmpty() && stack.peekLast().level >= heading.level) {
                    stack.removeLast();
                }
                stack.addLast(new HeadingLevel(heading.level, heading.title));
                current = new SectionChunk(copyPath(stack), heading.title);
                current.addLine(lineSlice);
            } else {
                if (current == null) {
                    current = new SectionChunk(Collections.singletonList("未命名段落"), "未命名段落");
                }
                current.addLine(lineSlice);
            }
        }
        if (current != null) {
            sections.add(current);
        }
        return finalizeSectionChunks(content, sections, strategyConfig);
    }

    private List<RagChunk> finalizeSectionChunks(String content, List<SectionChunk> sections, StrategyConfig strategyConfig) {
        List<RagChunk> result = new ArrayList<RagChunk>();
        int index = 1;
        for (SectionChunk section : sections) {
            TextRange baseRange = trimRange(content, section.start, section.end);
            if (baseRange == null) {
                continue;
            }
            String sectionContent = content.substring(baseRange.start, baseRange.end);
            if (!StringUtil.hasText(sectionContent) || sectionContent.equals(section.sourceLabel)) {
                continue;
            }
            if (sectionContent.length() <= safeChunkSize(strategyConfig)) {
                result.add(createChunk(
                    RagChunkTypes.SECTION,
                    section.sourceLabel,
                    section.headingPath,
                    sectionContent,
                    index++,
                    1,
                    1,
                    Collections.singletonList(baseRange)
                ));
                continue;
            }
            List<String> subContents = splitLongContent(sectionContent, strategyConfig.getChunkSize());
            int total = subContents.size();
            int relativeCursor = 0;
            for (int i = 0; i < subContents.size(); i++) {
                String subContent = subContents.get(i);
                TextRange relativeRange = findOrderedRange(sectionContent, subContent, relativeCursor, "章节分块");
                relativeCursor = relativeRange.end;
                result.add(createChunk(
                    RagChunkTypes.SECTION,
                    section.sourceLabel,
                    section.headingPath,
                    subContent,
                    index++,
                    i + 1,
                    total,
                    Collections.singletonList(relativeRange.offset(baseRange.start))
                ));
            }
        }
        return result;
    }

    private List<RagChunk> buildQaChunks(String content, StrategyConfig strategyConfig) {
        List<RagChunk> result = new ArrayList<RagChunk>();
        String currentQuestion = null;
        List<LineSlice> answerSlices = new ArrayList<LineSlice>();
        List<LineSlice> questionSlices = new ArrayList<LineSlice>();
        boolean answerStarted = false;
        int qaIndex = 1;

        for (LineSlice lineSlice : sliceLines(content)) {
            String line = lineSlice.trimmedLine;
            if (!StringUtil.hasText(line)) {
                continue;
            }
            Matcher questionMatcher = QUESTION_PREFIX.matcher(line);
            Matcher answerMatcher = ANSWER_PREFIX.matcher(line);
            if (questionMatcher.matches()) {
                qaIndex = flushQaChunk(
                    result,
                    content,
                    currentQuestion,
                    questionSlices,
                    answerSlices,
                    qaIndex,
                    strategyConfig);
                currentQuestion = questionMatcher.group(2).trim();
                questionSlices = new ArrayList<LineSlice>();
                answerSlices = new ArrayList<LineSlice>();
                questionSlices.add(lineSlice);
                answerStarted = false;
                continue;
            }
            if (answerMatcher.matches()) {
                if (!StringUtil.hasText(currentQuestion)) {
                    continue;
                }
                answerSlices.add(lineSlice);
                answerStarted = true;
                continue;
            }
            if (!StringUtil.hasText(currentQuestion)) {
                continue;
            }
            if (answerStarted) {
                answerSlices.add(lineSlice);
            } else if (!questionSlices.isEmpty()) {
                questionSlices.add(lineSlice);
            }
        }
        flushQaChunk(
            result,
            content,
            currentQuestion,
            questionSlices,
            answerSlices,
            qaIndex,
            strategyConfig);
        return result;
    }

    private int flushQaChunk(List<RagChunk> result,
                             String content,
                             String currentQuestion,
                             List<LineSlice> questionSlices,
                             List<LineSlice> answerSlices,
                             int qaIndex,
                             StrategyConfig strategyConfig) {
        if (!StringUtil.hasText(currentQuestion)) {
            return qaIndex;
        }
        if (answerSlices == null || answerSlices.isEmpty()) {
            return qaIndex;
        }
        TextRange questionRange = mergeLineSlices(questionSlices);
        TextRange rawAnswerRange = mergeLineSlices(answerSlices);
        TextRange answerRange = rawAnswerRange == null
            ? null
            : trimRange(content, rawAnswerRange.start, rawAnswerRange.end);
        if (questionRange == null || answerRange == null) {
            return qaIndex;
        }

        String question = currentQuestion.trim();
        String answer = content.substring(answerRange.start, answerRange.end);
        String qaPrefix = "问题：" + question + "\n答案：";
        int requestedTokens = Math.min(
            safeChunkSize(strategyConfig),
            RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT);
        int prefixTokens = BgeM3ChunkSafety.estimateContentTokens(qaPrefix);
        boolean includePrefix = prefixTokens
            < RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT;
        int answerTokenLimit = includePrefix
            ? Math.max(1, Math.max(requestedTokens, prefixTokens + 1) - prefixTokens)
            : RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT;
        List<TextRange> answerPartRanges = new ArrayList<TextRange>();
        for (BgeM3ChunkSafety.ChunkRange answerPart
            : BgeM3ChunkSafety.splitToTokenLimit(answer, answerTokenLimit)) {
            TextRange absoluteAnswerRange = trimRange(
                content,
                answerRange.start + answerPart.getStart(),
                answerRange.start + answerPart.getEnd());
            if (absoluteAnswerRange != null) {
                answerPartRanges.add(absoluteAnswerRange);
            }
        }
        int total = answerPartRanges.size();
        for (int i = 0; i < answerPartRanges.size(); i++) {
            TextRange absoluteAnswerRange = answerPartRanges.get(i);
            String answerFragment = content.substring(
                absoluteAnswerRange.start,
                absoluteAnswerRange.end);
            List<TextRange> sourceRanges = new ArrayList<TextRange>();
            sourceRanges.add(questionRange);
            sourceRanges.add(absoluteAnswerRange);
            RagChunk chunk = createChunk(
                RagChunkTypes.QA_PAIR,
                "Q" + qaIndex + " " + question,
                Collections.<String>emptyList(),
                includePrefix ? qaPrefix + answerFragment : answerFragment,
                result.size() + 1,
                i + 1,
                total,
                sourceRanges
            );
            chunk.setQuestion(question);
            chunk.setAnswer(answerFragment);
            chunk.getOptions().put(RagMetadataKeys.QA_GROUP_ID, "qa-" + qaIndex);
            result.add(chunk);
        }
        return qaIndex + 1;
    }

    private List<RagChunk> buildParagraphChunks(String content, StrategyConfig strategyConfig) {
        List<RagChunk> result = new ArrayList<RagChunk>();
        int index = 1;
        int currentIndex = 0;
        int maxIndex = content.length();
        while (currentIndex < maxIndex) {
            int endIndex = Math.min(currentIndex + safeChunkSize(strategyConfig), maxIndex);
            TextRange range = trimRange(content, currentIndex, endIndex);
            currentIndex = currentIndex + safeChunkSize(strategyConfig) - safeOverlap(strategyConfig);
            if (range == null) {
                continue;
            }
            result.add(createChunk(
                RagChunkTypes.PARAGRAPH,
                "分块 " + index,
                Collections.<String>emptyList(),
                content.substring(range.start, range.end),
                index,
                1,
                1,
                Collections.singletonList(range)
            ));
            index++;
        }
        return result;
    }

    private List<RagChunk> buildRegexChunks(String content, StrategyConfig strategyConfig) {
        String regex = StringUtil.hasText(strategyConfig.getRegex()) ? strategyConfig.getRegex() : "\\n\\s*\\n";
        List<RagChunk> result = new ArrayList<RagChunk>();
        int index = 1;
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(content);
        int segmentStart = 0;
        boolean retainRegexMatch = Boolean.TRUE.equals(
            strategyConfig.getRetainRegexMatch());
        while (matcher.find()) {
            index = addRegexChunk(result, content, segmentStart, matcher.start(), index);
            // 保留时将匹配内容归入下一分块；默认继续从匹配结束位置开始。
            segmentStart = retainRegexMatch ? matcher.start() : matcher.end();
        }
        addRegexChunk(result, content, segmentStart, content.length(), index);
        return result;
    }

    private List<String> splitLongContent(String content, Integer chunkSize) {
        int size = chunkSize == null || chunkSize.intValue() <= 0
            ? RagDefaults.CHUNK_SIZE
            : Math.min(chunkSize.intValue(), RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT);
        List<String> parts = new ArrayList<String>();
        for (BgeM3ChunkSafety.ChunkRange range
            : BgeM3ChunkSafety.splitToTokenLimit(content, size)) {
            String part = content.substring(range.getStart(), range.getEnd()).trim();
            if (StringUtil.hasText(part)) {
                parts.add(part);
            }
        }
        return parts;
    }

    /**
     * Applies the shared BGE-M3 context safety guard after strategy-specific splitting.
     *
     * @param chunks chunks produced by a semantic or length strategy
     * @return chunks whose conservative token estimates stay within the hard limit
     */
    private List<RagChunk> enforceBgeM3HardLimit(List<RagChunk> chunks) {
        List<RagChunk> result = new ArrayList<RagChunk>();
        for (RagChunk chunk : chunks) {
            String content = chunk.getContent();
            if (BgeM3ChunkSafety.isWithinHardLimit(content)) {
                result.add(chunk);
                continue;
            }
            List<BgeM3ChunkSafety.ChunkRange> parts =
                BgeM3ChunkSafety.splitToHardLimit(content);
            for (int i = 0; i < parts.size(); i++) {
                BgeM3ChunkSafety.ChunkRange range = parts.get(i);
                result.add(copyHardSplitChunk(
                    chunk,
                    content,
                    new TextRange(range.getStart(), range.getEnd()),
                    i + 1,
                    parts.size()));
            }
        }
        return result;
    }

    /**
     * Copies a strategy chunk while replacing only content and hard-split metadata.
     *
     * @param source original strategy chunk
     * @param sourceContent original chunk content
     * @param range selected content range
     * @param partNo forced part number
     * @param partTotal forced part count
     * @return copied hard-split chunk
     */
    private RagChunk copyHardSplitChunk(RagChunk source,
                                        String sourceContent,
                                        TextRange range,
                                        int partNo,
                                        int partTotal) {
        RagChunk copy = new RagChunk();
        copy.setChunkType(source.getChunkType());
        copy.setSourceLabel(source.getSourceLabel());
        copy.setHeadingPath(new ArrayList<String>(source.getHeadingPath()));
        copy.setContent(sourceContent.substring(range.start, range.end));
        copy.setQuestion(source.getQuestion());
        copy.setAnswer(RagChunkTypes.QA_PAIR.equals(source.getChunkType())
            ? copy.getContent()
            : source.getAnswer());
        copy.setPartNo(Integer.valueOf(partNo));
        copy.setPartTotal(Integer.valueOf(partTotal));
        copy.setWarnings(new ArrayList<String>(source.getWarnings()));
        copy.setOptions(new LinkedHashMap<String, Object>(source.getOptions()));
        copy.getOptions().put("hardSplit", Boolean.TRUE);
        copy.getOptions().put("hardSplitTokenLimit",
            Integer.valueOf(RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT));
        adjustSingleSourceRange(copy, sourceContent.length(), range);
        return copy;
    }

    /**
     * Narrows an exact single source range to the forced subrange.
     *
     * @param chunk copied hard-split chunk
     * @param sourceLength original chunk content length
     * @param relativeRange forced range relative to the original chunk
     */
    @SuppressWarnings("unchecked")
    private void adjustSingleSourceRange(RagChunk chunk, int sourceLength, TextRange relativeRange) {
        Object rawRanges = chunk.getOptions().get(RagMetadataKeys.SOURCE_RANGES);
        if (!(rawRanges instanceof List) || ((List<?>) rawRanges).size() != 1) {
            return;
        }
        Object rawRange = ((List<?>) rawRanges).get(0);
        if (!(rawRange instanceof Map)) {
            return;
        }
        Map<String, Object> sourceRange = (Map<String, Object>) rawRange;
        Object rawStart = sourceRange.get("start");
        Object rawEnd = sourceRange.get("end");
        if (!(rawStart instanceof Number) || !(rawEnd instanceof Number)) {
            return;
        }
        int sourceStart = ((Number) rawStart).intValue();
        int sourceEnd = ((Number) rawEnd).intValue();
        if (sourceEnd - sourceStart != sourceLength) {
            return;
        }
        chunk.getOptions().put(RagMetadataKeys.SOURCE_RANGES,
            toSourceRangeMaps(Collections.singletonList(
                new TextRange(sourceStart + relativeRange.start, sourceStart + relativeRange.end))));
    }

    private List<RagChunk> postProcess(List<RagChunk> chunks) {
        List<RagChunk> result = new ArrayList<RagChunk>();
        Set<String> dedup = new HashSet<String>();
        int index = 1;
        for (RagChunk chunk : chunks) {
            String content = chunk.getContent() == null ? "" : chunk.getContent().trim();
            if (!StringUtil.hasText(content)) {
                continue;
            }
            if (content.length() < 10 && !RagChunkTypes.QA_PAIR.equals(chunk.getChunkType())) {
                continue;
            }
            String dedupKey = content.replaceAll("\\s+", " ");
            if (!dedup.add(dedupKey)) {
                continue;
            }
            chunk.setChunkId("chunk-" + index);
            chunk.setCharCount(Integer.valueOf(content.length()));
            chunk.setTokenEstimate(Integer.valueOf(
                Math.max(1, BgeM3ChunkSafety.estimateContentTokens(content))));
            result.add(chunk);
            index++;
        }
        return result;
    }

    private RagChunk createChunk(String chunkType,
                                 String sourceLabel,
                                 List<String> headingPath,
                                 String content,
                                 int index,
                                 int partNo,
                                 int partTotal,
                                 List<TextRange> sourceRanges) {
        RagChunk chunk = new RagChunk();
        chunk.setChunkId("chunk-" + index);
        chunk.setChunkType(chunkType);
        chunk.setSourceLabel(sourceLabel);
        chunk.setHeadingPath(new ArrayList<String>(headingPath));
        chunk.setContent(content.trim());
        chunk.setPartNo(Integer.valueOf(partNo));
        chunk.setPartTotal(Integer.valueOf(partTotal));
        if (!headingPath.isEmpty()) {
            chunk.getOptions().put(RagMetadataKeys.HEADING_PATH, new ArrayList<String>(headingPath));
        }
        if (RagChunkTypes.SECTION.equals(chunkType)) {
            chunk.getOptions().put(RagMetadataKeys.SOURCE_LABEL, sourceLabel);
        }
        if (sourceRanges != null && !sourceRanges.isEmpty()) {
            chunk.getOptions().put(RagMetadataKeys.SOURCE_RANGES, toSourceRangeMaps(sourceRanges));
        }
        return chunk;
    }

    private int addRegexChunk(List<RagChunk> result, String content, int rawStart, int rawEnd, int index) {
        TextRange range = trimRange(content, rawStart, rawEnd);
        if (range == null) {
            return index;
        }
        result.add(createChunk(
            RagChunkTypes.PARAGRAPH,
            "正则分块 " + index,
            Collections.<String>emptyList(),
            content.substring(range.start, range.end),
            index,
            1,
            1,
            Collections.singletonList(range)
        ));
        return index + 1;
    }

    private List<LineSlice> sliceLines(String content) {
        List<LineSlice> result = new ArrayList<LineSlice>();
        if (content == null || content.isEmpty()) {
            return result;
        }
        int start = 0;
        for (int i = 0; i <= content.length(); i++) {
            if (i < content.length() && content.charAt(i) != '\n') {
                continue;
            }
            String rawLine = content.substring(start, i);
            result.add(new LineSlice(start, i, rawLine));
            start = i + 1;
        }
        return result;
    }

    private String joinLineSlices(List<LineSlice> slices) {
        List<String> values = new ArrayList<String>();
        for (LineSlice slice : slices) {
            values.add(slice.trimmedLine);
        }
        return joinAndTrim(values);
    }

    private List<TextRange> buildQaSourceRanges(List<LineSlice> questionSlices, List<LineSlice> answerSlices) {
        List<TextRange> result = new ArrayList<TextRange>();
        TextRange questionRange = mergeLineSlices(questionSlices);
        if (questionRange != null) {
            result.add(questionRange);
        }
        TextRange answerRange = mergeLineSlices(answerSlices);
        if (answerRange != null) {
            result.add(answerRange);
        }
        return result;
    }

    private TextRange mergeLineSlices(List<LineSlice> slices) {
        if (slices == null || slices.isEmpty()) {
            return null;
        }
        return new TextRange(slices.get(0).start, slices.get(slices.size() - 1).end);
    }

    private TextRange trimRange(String content, int rawStart, int rawEnd) {
        int start = Math.max(0, rawStart);
        int end = Math.min(content.length(), rawEnd);
        while (start < end && Character.isWhitespace(content.charAt(start))) {
            start++;
        }
        while (end > start && Character.isWhitespace(content.charAt(end - 1))) {
            end--;
        }
        if (start >= end) {
            return null;
        }
        return new TextRange(start, end);
    }

    private TextRange findOrderedRange(String baseContent, String chunkContent, int searchStart, String label) {
        int index = baseContent.indexOf(chunkContent, Math.max(0, searchStart));
        if (index < 0 && searchStart > 0) {
            index = baseContent.indexOf(chunkContent);
        }
        if (index < 0) {
            throw new IllegalStateException(label + "无法定位原文区间");
        }
        return new TextRange(index, index + chunkContent.length());
    }

    private List<Map<String, Object>> toSourceRangeMaps(List<TextRange> sourceRanges) {
        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
        for (TextRange sourceRange : sourceRanges) {
            Map<String, Object> item = new LinkedHashMap<String, Object>();
            item.put("start", Integer.valueOf(sourceRange.start));
            item.put("end", Integer.valueOf(sourceRange.end));
            result.add(item);
        }
        return result;
    }

    private int safeChunkSize(StrategyConfig strategyConfig) {
        Integer chunkSize = strategyConfig.getChunkSize();
        return chunkSize == null || chunkSize.intValue() <= 0 ? RagDefaults.CHUNK_SIZE : chunkSize.intValue();
    }

    private int safeOverlap(StrategyConfig strategyConfig) {
        Integer overlapSize = strategyConfig.getOverlapSize();
        int overlap = overlapSize == null || overlapSize.intValue() < 0
            ? RagDefaults.OVERLAP_SIZE
            : overlapSize.intValue();
        int chunkSize = safeChunkSize(strategyConfig);
        if (overlap >= chunkSize) {
            throw new IllegalArgumentException("overlapSize must be smaller than chunkSize");
        }
        return overlap;
    }

    private String joinAndTrim(List<String> lines) {
        String value = String.join("\n", lines).trim();
        return value.replaceAll("\\n{3,}", "\n\n");
    }

    private List<String> copyPath(Deque<HeadingLevel> stack) {
        List<String> path = new ArrayList<String>();
        for (HeadingLevel item : stack) {
            path.add(0, item.title);
        }
        return path;
    }

    private static class SectionChunk {
        private final List<String> headingPath;
        private final String sourceLabel;
        private final List<String> lines = new ArrayList<String>();
        private int start = -1;
        private int end = -1;

        private SectionChunk(List<String> headingPath, String sourceLabel) {
            this.headingPath = headingPath;
            this.sourceLabel = sourceLabel;
        }

        private void addLine(LineSlice lineSlice) {
            if (start < 0) {
                start = lineSlice.start;
            }
            end = lineSlice.end;
            lines.add(lineSlice.rawLine);
        }
    }

    private static class HeadingLevel {
        private final int level;
        private final String title;

        private HeadingLevel(int level, String title) {
            this.level = level;
            this.title = title;
        }
    }

    private static class OutlineHeading {
        private static final Pattern CHINESE_CHAPTER = Pattern.compile("^第[一二三四五六七八九十百零两0-9]+[章节篇部分卷]\\s*(.*)$");
        private static final Pattern CHINESE_SECTION = Pattern.compile("^([一二三四五六七八九十百零]+[、.．])\\s*(\\S+.*)$");
        private static final Pattern CHINESE_SUBSECTION = Pattern.compile("^[（(]([一二三四五六七八九十百零0-9]+)[)）]\\s*(\\S+.*)$");
        private static final Pattern NUMERIC_SECTION = Pattern.compile("^([0-9]+(?:\\.[0-9]+){0,4})\\s+(\\S+.*)$");
        private static final Pattern ENGLISH_SECTION = Pattern.compile("^(Chapter|Section|Part)\\s+([0-9IVXLC]+)(.*)$", Pattern.CASE_INSENSITIVE);
        private static final Pattern ENGLISH_ROMAN = Pattern.compile("^([IVXLC]+)[.、)\\s-]+(\\S+.*)$");

        private final int level;
        private final String title;

        private OutlineHeading(int level, String title) {
            this.level = level;
            this.title = title;
        }

        private static OutlineHeading parse(String line) {
            Matcher matcher = CHINESE_CHAPTER.matcher(line);
            if (matcher.matches()) {
                return new OutlineHeading(1, line.trim());
            }
            matcher = CHINESE_SECTION.matcher(line);
            if (matcher.matches()) {
                return new OutlineHeading(2, line.trim());
            }
            matcher = CHINESE_SUBSECTION.matcher(line);
            if (matcher.matches()) {
                return new OutlineHeading(3, line.trim());
            }
            matcher = NUMERIC_SECTION.matcher(line);
            if (matcher.matches()) {
                String code = matcher.group(1);
                int level = code.split("\\.").length;
                return new OutlineHeading(level, line.trim());
            }
            matcher = ENGLISH_SECTION.matcher(line);
            if (matcher.matches()) {
                String prefix = matcher.group(1).toLowerCase();
                int level = "chapter".equals(prefix) ? 1 : ("section".equals(prefix) ? 2 : 1);
                return new OutlineHeading(level, line.trim());
            }
            matcher = ENGLISH_ROMAN.matcher(line);
            if (matcher.matches()) {
                return new OutlineHeading(2, line.trim());
            }
            return null;
        }
    }

    private static class LineSlice {
        private final int start;
        private final int end;
        private final String rawLine;
        private final String trimmedLine;

        private LineSlice(int start, int end, String rawLine) {
            this.start = start;
            this.end = end;
            this.rawLine = rawLine == null ? "" : rawLine;
            this.trimmedLine = this.rawLine.trim();
        }
    }

    private static class TextRange {
        private final int start;
        private final int end;

        private TextRange(int start, int end) {
            this.start = start;
            this.end = end;
        }

        private TextRange offset(int offset) {
            return new TextRange(start + offset, end + offset);
        }
    }
}
