#!/usr/bin/env python3
"""
NiceGUI-based web proofing tool with configurable fields and data sources.
Configuration is loaded from ./data/config.json by default, or switched via toolbar.
Supports showing multiple records at once (configurable).
"""

from __future__ import annotations

import asyncio
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Tuple

from abc import ABC, abstractmethod
from nicegui import ui, app
from fastapi.responses import Response
from difflib import SequenceMatcher
from PIL import Image, ImageOps
import fitz
import io
#import re
import regex as re

# =============================
# Global Config State
# =============================
CONFIG_PATH = Path('./japanese/config.json')
CONFIG: dict = {}

OUTPUT_PATH: Path = Path('')
IMAGE_CONFIG: dict = None
CANDIDATES_CONFIG: Dict = {}
KEYNAME: str = 'no'
FIELDS: List[str] = []
FIELD_MAP: Dict[str, dict] = {}
FIELD_LABELS: Dict[str, str] = {}
FIELD_MODES: Dict[str, str] = {}
FIELD_PDF_KEYS: Dict[str, str] = {}
TITLE: str = 'JSON 文本校对工具 (Larousse)'
ITEMS_PER_PAGE: int = 1
AUTOSAVE_SECONDS = 60

# 配置 row_mode
ROW_MODE = bool(CONFIG.get('row_mode', False))
candidates: dict = {}

store: BaseStore
filter_candidate = None
filtered_indices = []
main_column = None
record_status_label = None
compact_container = None
timer = None
image_source: ImageSource = None

def get_config_files():
    config_dir = Path('./config')
    return [str(p) for p in config_dir.glob('*.json')]

# =============================
# Config Loader
# =============================

def load_config(path: Path):
    global CONFIG, OUTPUT_PATH, IMAGE_CONFIG, CANDIDATES_CONFIG, KEYNAME,ROW_MODE,AUTOSAVE_SECONDS
    global FIELDS, FIELD_LABELS, FIELD_MODES, FIELD_PDF_KEYS, candidates, image_source, ITEMS_PER_PAGE, store, timer
    global FIELD_MAP
    with open(path, 'r', encoding='utf-8') as f:
        CONFIG = json.load(f)
    KEYNAME = CONFIG['key_name']
    IMAGE_CONFIG = CONFIG.get('image_source')
    if IMAGE_CONFIG:
        image_source = ImageSource(IMAGE_CONFIG)
    CANDIDATES_CONFIG = CONFIG['candidates']
    
    ITEMS_PER_PAGE = int(CONFIG.get('items_per_page', 1))

    FIELD_MAP = CONFIG['field_config']
    FIELDS = list(FIELD_MAP.keys())
    FIELD_LABELS = {k: v['label'] for k, v in FIELD_MAP.items()}
    FIELD_MODES = {k: v.get('mode', 'normal') for k, v in FIELD_MAP.items()}
    FIELD_PDF_KEYS = {k: v.get('pdf_key') for k, v in FIELD_MAP.items()}

    # load candidates
    if isinstance(CANDIDATES_CONFIG, dict):
        candidates = {}
        for key, path_or_cfg in CANDIDATES_CONFIG.items():
            candidate_store = create_store(path_or_cfg, KEYNAME)
            if candidate_store:
                candidate_store.load()
                candidates[key] = candidate_store
                    

    AUTOSAVE_SECONDS = int(CONFIG.get('autosave_seconds', 60))
    if timer is not None:
        timer.delete()
    if AUTOSAVE_SECONDS > 0:
        timer = ui.timer(AUTOSAVE_SECONDS, save_now)
    ROW_MODE = bool(CONFIG.get('row_mode', True))

    input_file = CONFIG.get('input_file')
    OUTPUT_PATH = CONFIG.get('output_file', input_file)
    store = create_store(input_file, KEYNAME)
    store.load()
# =============================
# Data Layer
# =============================

def safe_read_json(path):
    if isinstance(path, str):
        path = Path(path)
    with path.open('r', encoding='utf-8') as f:
        return json.load(f)


def safe_write_json(path: Path | str, data: List[dict]) -> None:
    if isinstance(path, str):
        path = Path(path)
    tmp = path.with_suffix('.tmp')
    with tmp.open('w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    tmp.replace(path)

class ImageSource:
    def __init__(self, cfg: dict):
        self.path = Path(cfg['path'])
        self.cfg = cfg
        self.type = "dir" if self.path.is_dir() else "pdf" if self.path.suffix.lower() == '.pdf' else None
        if self.type == 'dir':
            if not self.path.exists() or not self.path.is_dir():
                raise ValueError(f'图片目录不存在: {self.path}')
        elif self.type == 'pdf':
            if not self.path.exists() or not self.path.is_file():
                raise ValueError(f'图片PDF文件不存在: {self.path}')
            self.pdf_doc = fitz.open(self.path)
        else:
            raise ValueError(f'不支持的图片类型: {self.type}')
        position_source = cfg.get('position_source')
        if position_source:
            self.position_store = safe_read_json(position_source)
        self.position_key = cfg.get('position_key','position')
        
    def get_position(self, key: str, field:str = None) -> dict | List[dict] | None:
        if self.position_store:
            positions = [r for r in self.position_store if r.get(KEYNAME) == key]
            if not positions:
                return None
            if len(positions) == 1:
                return positions[0]
            return positions
        return None
    

    def get_image(self, key: str, field:str = None) -> Image.Image | None:
        position = self.get_position(key, field)[self.position_key]
        if isinstance(position,dict):
        #if not page_key in rec or not bbox_key in rec:
        #    return Response(status_code=404)
            bbox_key = f"{field}_bbox" if field else "bbox"
            page_key = f"{field}_page" if field else "page"
            rect = fitz.Rect(*position[bbox_key])
            page_no = position[page_key] - 1
            pix = self.get_image_by_page(page_no)
            return pix
        elif isinstance(position,list):
            pix = merge_image(self.pdf_doc, position)
            buffer = io.BytesIO()
            pix.save(buffer, format="PNG")   # 保存到内存里
            png_bytes = buffer.getvalue()    # 取出 PNG 字节流
            return png_bytes
    def get_image_by_page(self, page_num:int) -> Image.Image | None:
        if self.type == 'dir':
            img_path = self.path / f"{page_num:04}.png"
            if img_path.exists() and img_path.is_file():
                try:
                    img = Image.open(img_path)
                    #img = ImageOps.exif_transpose(img)
                    return img
                except Exception as e:
                    print(f'无法打开图片 {img_path}: {e}')
                    return None
            else:
                return None
        elif self.type == 'pdf':
            try:
                if 0 <= page_num < len(self.pdf_doc):
                    page = self.pdf_doc[page_num]
                    pix = page.get_pixmap()
                    img_data = pix.tobytes("png")
                    img = Image.open(io.BytesIO(img_data))
                    return img
                else:
                    return None
            except Exception as e:
                print(f'无法从PDF获取图片 {page_num}: {e}')
                return None
        return None


class BaseStore(ABC):
    def __init__(self, path_or_cfg, key_name):
        if isinstance(path_or_cfg, str):
            self.path = Path(path_or_cfg)
            self.cfg = {}
        else:
            self.path = path_or_cfg['path']
            self.cfg = path_or_cfg
        if 'key' in self.cfg:
            self.key_name = path_or_cfg['key']
        else:
            self.key_name = key_name
        self.records = []
        self.data_map = {}
        self.dirty = False

    @abstractmethod
    def load(self):
        ...

    @abstractmethod
    def save(self,path = None):
        ...

    def set_field(self, index: int, field_name: str, value: str):
        self.records[index][field_name] = value
        self.dirty = True

    def get(self, key_value):
        if key_value in self.data_map:
            return self.data_map[key_value]
        for rec in self.records:
            if rec.get(self.key_name) == key_value:
                return rec
        return None

class JsonStore(BaseStore):
    def load(self):
        self.data = safe_read_json(self.path)
        if isinstance(self.data,list):
            self.records = self.data
            self.data_map = {rec[self.key_name]: rec for rec in self.data if self.key_name in rec}
        elif isinstance(self.data,dict):
            for value in self.data.values():
                self.records.append(value)
            self.data_map = self.data

    def save(self,path = None):
        if self.dirty:
            safe_write_json(path, self.data)
            self.dirty = False

    def get(self, key_value):
        if key_value in self.data_map:
            return self.data_map[key_value]
        return None

class XlsxStore(BaseStore):
    def load(self):
        import openpyxl
        wb = openpyxl.load_workbook(self.path)
        sheet = wb.active
        headers = [c.value for c in sheet[1]]
        self.records = []
        for row in sheet.iter_rows(min_row=2, values_only=True):
            rec = dict(zip(headers, row))
            self.records.append(rec)

    def save(self,path = None):
        import openpyxl
        if not self.dirty:
            return
        wb = openpyxl.Workbook()
        sheet = wb.active
        if not self.records:
            return
        headers = list(self.records[0].keys())
        sheet.append(headers)
        for rec in self.records:
            sheet.append([rec.get(h, "") for h in headers])
        wb.save(self.path)
        self.dirty = False

class DocxStore(BaseStore):
    def load(self):
        import docx
        doc = docx.Document(self.path)
        self.records = []
        for i, para in enumerate(doc.paragraphs):
            self.records.append({self.key_name: i+1, 'text': para.text})

    def save(self,path = None):
        import docx
        if not self.dirty:
            return
        doc = docx.Document()
        for rec in self.records:
            doc.add_paragraph(rec.get('text', ''))
        doc.save(path)
        self.dirty = False

class PdfStore(BaseStore):
    def load(self):
        pdf = fitz.open(self.path)
        self.records = []
        for i, page in enumerate(pdf):
            self.records.append({self.key_name: i+1, 'text': page.get_text(), 'page':i+1})

    def save(self,path = None):
        # PDF 保存复杂，默认导出为txt
        if not self.dirty:
            return
        txt_path = path.with_suffix('.out.txt')
        with txt_path.open('w', encoding='utf-8') as f:
            for rec in self.records:
                f.write(rec.get('text', '') + '\n\n')
        self.dirty = False

class TxtStore(BaseStore):
    def __init__(self, cfg, key_name):
        super().__init__(cfg['path'], key_name)
        self.start_pat = re.compile(cfg.get('start_pattern', r'^'), re.M)
        self.end_pat = re.compile(cfg.get('end_pattern', r'\Z'), re.M)
        self.split_pat = re.compile(cfg.get('split_pattern', r'\n'), re.M)
        self.page_pat =  re.compile(cfg.get('page_pattern'), re.M) if cfg.get('page_pattern') else None
        self.key_pat =  re.compile(cfg.get('key_pattern'), re.M) if cfg.get('key_pattern') else None
        self.is_grammar = cfg.get('is_grammar', False)
        self.fields_cfg = cfg.get('fields', {})
        self.grammar_path = cfg.get('grammar_path')
        self.key_name = cfg.get('key')
        self.parser = None
        self.modified_recs = set()
        if self.is_grammar and self.grammar_path:
            grammar_text = Path(self.grammar_path).read_text(encoding='utf-8')
            self.parser = Lark(grammar_text, parser='lalr')
        elif not self.is_grammar and not self.key_pat and self.key_name and self.key_name in self.fields_cfg:
            self.key_pat = re.compile(self.fields_cfg[self.key_name])
        self.auto_combine = cfg.get('auto_combine',False)

        self.prefix = ''
        self.suffix = ''

    def load(self):
        text = Path(self.path).read_text(encoding='utf-8')

        # 定位 start / end
        start_match = self.start_pat.search(text)
        end_match = self.end_pat.search(text)
        start_idx = start_match.end() if start_match else 0
        end_idx = end_match.start() if end_match else len(text)

        self.prefix = text[:start_idx]
        self.suffix = text[end_idx:]
        core_text = text[start_idx:end_idx]

        # 分割 + 保留分隔符
        self.records = []
        last_pos = 0
        cur_page = 0
    
        cur_num = 0
        if self.split_pat.pattern == r'\n':
            self.lines = core_text.splitlines()
            for line_no,line in enumerate(self.lines):
                if self.page_pat and (match := self.page_pat.match(line)):
                    if cur_page != int(match.group(1)):
                        cur_num = 0
                    cur_page = int(match.group(1))
                    continue
                if self.key_pat and self.auto_combine:
                    if self.key_pat.search(line):
                        rec = {"no": cur_num+1, 'text': line, 'line_no': line_no}
                    elif self.records:
                        rec = self.records[-1]
                        rec['text'] += '\n' + line
                        if not isinstance(rec['line_no'],list):
                            rec['line_no'] = [rec['line_no']]
                        rec['line_no'].append(line_no)

                else:
                    rec = {"no": cur_num+1, 'text': line, 'line_no': line_no}
                if self.page_pat:
                    rec['page'] = cur_page
                self.records.append(rec)
                cur_num += 1
        else:
            self.lines = []
            self.separators = []
            for i, match in enumerate(self.split_pat.finditer(core_text)):
                segment = core_text[last_pos:match.start()]
                separator = match.group(0)
                self.lines.append(segment)
                self.separators.append(separator)

                if self.page_pat and self.page_pat.match(segment):
                    match = self.page_pat.match(segment)
                    if cur_page != int(match.group(1)):
                        cur_num = 0
                    cur_page = int(match.group(1))
                    continue
                if self.key_pat and self.auto_combine:
                    if self.key_pat.search(line):
                        rec = {"no": cur_num+1, 'text': line, 'line_no': line_no}
                    elif self.records:
                        rec = self.records[-1]
                        rec['text'] += '\n' + line
                        if not isinstance(rec['line_no'],list):
                            rec['line_no'] = [rec['line_no']]
                        rec['line_no'].append(line_no)
                else:
                    rec = {"no": cur_num+1, 'text': segment, "line_no": len(self.lines)-1}
                self.records.append(rec)
                if self.page_pat:
                    rec['page'] = cur_page
                last_pos = match.end()
                cur_num += 1

            # 最后一个片段
            if last_pos < len(core_text):
                segment = core_text[last_pos:]
                self.lines.append(segment)
                self.separators.append("")
                rec = {"no": cur_num+1, 'text': core_text[last_pos:], "line_no": len(self.lines)-1}
                if self.page_pat:
                    rec['page'] = cur_page
                self.records.append(rec)

        for i, rec in enumerate(self.records):
            seg = rec['text']
            if self.is_grammar:
                
                tree: Tree = self.parser.parse(seg)
                rec['_tree'] = tree
                for child in tree.children:
                    if isinstance(child, Tree):
                        rec[child.data] = seg[child.meta.start_pos:child.meta.end_pos]
            else:
                for fname, regex in self.fields_cfg.items():
                    for m in re.finditer(regex, seg, re.M):
                        rec.setdefault('_matches', {})[fname] = (m.start(), m.end(), m.group(0))
                        rec[fname] = m.group(1) if m.groups() else m.group(0)
                        if self.key_name and fname == self.key_name:
                            self.data_map[rec[fname]] = rec


    def set_field(self, index: int, field_name: str, value: str):
        rec = self.records[index]
        if not self.is_grammar and '_matches' in rec and field_name in rec['_matches']:
            start, end, old = rec['_matches'][field_name]
            raw = rec['text']
            rec['text'] = raw[:start] + value + raw[end:]
            rec['_matches'][field_name] = (start, start+len(value), value)
        elif self.is_grammar and '_tree' in rec:
            # 找到对应子树位置替换
            tree: Tree = rec['_tree']
            new_text = list(rec['text'])
            for child in tree.children:
                if isinstance(child, Tree) and child.data == field_name:
                    s, e = child.meta.start_pos, child.meta.end_pos
                    new_text[s:e] = value
                    child.meta.start_pos, child.meta.end_pos = s, s+len(value)
            rec['text'] = ''.join(new_text)
        rec[field_name] = value
        self.dirty = True
        self.modified_recs.add(index)

    def save(self,path = None):
        if not self.dirty:
            return
        for idx in self.modified_recs:
            rec = self.records[idx]
            if isinstance(rec['line_no'],list):
                text_list = rec['text'].splitlines()
                if len(text_list) < rec['line_no']:
                    text_list.extend(['']*len(rec['line_no'])- len(text_list))
                for line_no,text in zip(rec['line_no'],text_list):
                    self.lines[line_no] = text
            else:
                self.lines[rec['line_no']] = rec['text']
        if self.split_pat.pattern == r'\n':
            out_text = '\n'.join(self.lines)
        else:
            out_text = ''
            for line,sep in zip(self.lines,self.separators):
                out_text += line + sep
        final_text = self.prefix + ''.join(out_text) + self.suffix
        Path(path).write_text(final_text, encoding='utf-8')
        self.dirty = False
        self.modified_recs = set()

class LarousseTxtStore(BaseStore):
    def load(self):
        text = Path(self.path).read_text(encoding='utf-8')

        start_pos = text.find('〈1〉')
        
        self.prefix = text[:start_pos]
        core_text = text[start_pos:]
        self.lines = core_text.splitlines()

        # 分割 + 保留分隔符
        self.records = []

        words = []
        cur_word = None
        cur_page = 0
        page_start = False
        
        cur_no = 0
        cur_page_no = 0
        for line_no,line in enumerate(self.lines):
            if not line.strip():
                continue
            if match := re.match(r'〈(\d+)〉',line):
                cur_page = int(match.group(1))
                page_start = True
                cur_page_no = 0

            elif page_start and re.fullmatch(r'[A-Z]+',line):
                pass
            elif re.match(r'(?i)^(\d+\.\s*)?\*? *(([A-Za-zàâçéèêëîïôöûùüÿñæœ][a-zàâçéèêëîïôöûùüÿñæœ\-\']*( [a-zàâçéèêëîïôöûùüÿñæœ\-\']+){,2} *(, *[a-zéèêëîïôöûùü]+ *){,2})( *(ou|et) *\*?[a-zàâçéèêëîïôöûùüÿñæœ\-\']+( [a-zàâçéèêëîïôöûùüÿñæœ\-\']+)? *(, *[a-zéèêëîïôöûùü]+ *){,2})?( \(de\) *)?\[[^\u4e00-\u9fff]+|(\-?[A-Z]\. ?){2,5}(\[[^\u4e00-\u9fff]+|(, )?sigle)|[a-zàâçéèêëîïôöûùüÿñæœ\-\']+(?: ?(n|[mf]|adj|v|[it]|adv|inv|pl|pr|prép|ind|loc|pron|poss|indéf|relat|et interr|dém|déf|interj|art|impers)\.){,4}(, [a-zàâçéèêëîïôöûùüÿñæœ\-\']+(?: *(n|[mf]|adj|v|[it]|adv|inv|pl|pr|prép|ind|loc|pron|poss|indéf|relat|et interr|dém|déf|interj|art|impers)\.){,4}){,3} *→|[A-Z][a-zàâçéèêëîïôöûùüÿñæœ\-\']+ ?\([a-zàâçéèêëîïôöûùüÿñæœ ]+?d[e\']\)|[a-zàâçéèêëîïôöûùüÿñæœ\-\']+\-, (préf\.|préfixe)|T\. G\. V\.)',line)\
                or not page_start:
                cur_no += 1
                cur_page_no += 1
                word = {'text': line, 'page': cur_page, 'line':line_no, 'no': cur_no, 'id':f"{cur_page}.{cur_page_no}"}
                headword= re.match(r'(?i)^((\d+\.\s*)?\*? *([a-zàâçéèêëîïôöûùüÿñæœ\-\']+(?: [a-zàâçéèêëîïôöûùüÿñæœ\-\']+){,4}|(?:\-?[A-Z]\. ?){2,5}|b\.a\.\-ba))(?=,| *sigle| *\(| *\[| [a-zàâçéèêëîïôöûùüÿñæœ]+\.| ou| →)',line)
                if headword:
                    word['headword'] = headword.group(1).strip()
                cur_word = word
                self.records.append(word)
                page_start = False
            else:
                cur_word['text'] += '\n' + line
                if isinstance(cur_word['page'],int):
                    cur_word['page'] = [cur_word['page']]
                    cur_word['line'] = [cur_word['line']]
                if not cur_page in cur_word['page']:
                    cur_word['page'].append(cur_page)
                cur_word['line'].append(line_no)
                page_start = False


    def save(self,path = None):
        if not self.dirty:
            return
        for rec in self.records:
            if isinstance(rec['line'],list):
                for line_no,text in zip(rec['line'],rec['text'].splitlines()):
                    self.lines[line_no] = text
            else:
                self.lines[rec['line']] = rec['text']
        final_text = self.prefix + '\n'.join(self.lines)
        Path(path).write_text(final_text, encoding='utf-8')
        self.dirty = False

store_extension_map = {
    '.json': JsonStore,
    '.xlsx': XlsxStore,
    '.docx': DocxStore,
    '.pdf': PdfStore,
    '.txt': TxtStore,
}

store_type_map = {
    'larousse': LarousseTxtStore,
}

def create_store(path_or_cfg, key_name: str) -> BaseStore:
    if isinstance(path_or_cfg, str):
        path = path_or_cfg
    else:
        path = path_or_cfg['path']
    ext = Path(path).suffix.lower()
    type = path_or_cfg.get('type') if isinstance(path_or_cfg, dict) else None
    if type in store_type_map:
        return store_type_map[type](path_or_cfg, key_name)
    elif ext in store_extension_map:
        return store_extension_map[ext](path_or_cfg, key_name)
    else:
        raise ValueError(f'不支持的文件类型: {ext}')
brackets = {
    '(': ')', '[': ']', '{': '}',
    '（': '）', '【': '】', '｛': '｝',
    '《': '》', '「': '」', '『': '』',
    '〔': '〕', '〖': '〗', '〈': '〉',
    '⟪': '⟫', '<"': '>', '«':'»'
}
reverse_brackets = {value:key for key,value in brackets.items()}
def check_brackets(s: str):
    # 定义所有括号的对应关系
    opening = set(brackets.keys())
    closing = set(brackets.values())
    stack = []

    errors = []

    for idx, char in enumerate(s):  # 下标从1开始，便于提示
        if char in opening:  # 左括号入栈
            stack.append((char, idx))
        elif char in closing:  # 遇到右括号
            if not stack:
                errors.append(('noleft',0,reverse_brackets[char],idx,char))
                continue
                #return f"第 {idx} 个字符 '{char}' 没有匹配的左括号"
            last, pos = stack.pop()
            if brackets[last] != char:
                errors.append(('nomatch',pos,last,idx,char))
                #return f"第 {idx} 个字符 '{char}' 与第 {pos} 个字符 '{last}' 不匹配"

    while stack:
        last, pos = stack.pop()
        errors.append(('noright',pos,last,idx,brackets[last]))
        #return f"第 {pos} 个字符 '{last}' 没有匹配的右括号"

    return errors
def gen_bracket_fix_candidate(text):
    result = check_brackets(text)
    if not result:
        return text
    chars = list(text)
    fixes = []
    for t,lpos,lchar,rpos,rchar in result:
        if t == 'nomatch':
            fixes.append((lpos,lchar,reverse_brackets[rchar]))
            fixes.append((rpos,rpos,brackets[lchar]))
        elif t == 'noleft':
            fixes.append((rpos,rchar,''))
            match = re.search(r'(?r)\b.',text,endpos=rpos-1)
            if match:
                fixes.append((match.start(),'',lchar))
        elif t == 'noright':
            fixes.append((lpos,lchar,''))
            match = re.search(r'.\b',text,pos=lpos+1)
            if match:
                fixes.append((match.end(),'',rchar))
    for pos, old, new in sorted(fixes,reverse=True):
        if old == '':
            chars.insert(pos,new)
        elif new == '':
            del chars[pos]
        else:
            chars[pos] = new
    return ''.join(chars)



def make_filter(f_cfg):
    """返回一个可调用对象 filter_func(record)->bool"""
    ftype = f_cfg.get("type")
    if ftype == "regex":
        field = f_cfg["field"]
        pattern = re.compile(f_cfg["pattern"])
        inverse = f_cfg.get("inverse", False)
        if inverse:
            return lambda r: not bool(pattern.search(str(r.get(field, ""))))
        else:
            return lambda r: bool(pattern.search(str(r.get(field, ""))))
    elif ftype == "paren_match":
        field = f_cfg["field"]
        return lambda r: bool(check_brackets(str(r.get(field, ""))))
    elif ftype == "lambda":
        # 用 eval 动态构造函数（注意安全，配置要可信）
        code = f_cfg["code"]
        func = eval(code)
        if not callable(func):
            raise ValueError("lambda filter must be callable")
        return func
    elif ftype == "candidate_match":
        # 检查候选来源是否完全一致
        source = f_cfg.get("source")  # 可选，指定候选来源
        ignore_pattern = f_cfg.get("ignore_pattern")
        match_count = f_cfg.get('match_count',0)
        match_ratio = f_cfg.get('match_ratio',0)
        ignore_re = re.compile(ignore_pattern) if ignore_pattern else None
        def _f(r):
            for field in FIELDS:
                cands = get_candidate(r, field)
                if cands:
                    base = r.get(field, "")
                    if ignore_re:
                        base = ignore_re.sub("",base)
                    
                    if source and source in cands:
                        target = cands[source]
                        if ignore_pattern:
                            target = ignore_re.sub("",target)
                        if base != target:
                            return True
                    else:
                        cur_match_count = 0
                        for target in cands.values():
                            if ignore_pattern:
                                target = ignore_re.sub("",target)
                            if base == target:
                                cur_match_count += 1
                        if (match_count and cur_match_count < match_count) or \
                            (match_ratio and cur_match_count / len(cands) < match_ratio):
                            return True
            return False
        return _f
    elif ftype == "candidate_half_mismatch":
        # 特殊：检查候选来源是否至少一半不一致
        def _f(r):
            from difflib import SequenceMatcher
            result = []
            for field in FIELDS:
                cands = get_candidate(r, field)
                if cands:
                    base = r.get(field, "")
                    for v in cands.values():
                        sm = SequenceMatcher(None, base, v)
                        if sm.ratio() < 0.9:
                            result.append(False)
                        else:
                            result.append(True)
            # True 表示合格 -> 所以要取返回 False 的
            return all(result) if result else True
        return _f
    else:
        raise ValueError(f"未知过滤器类型: {ftype}")

def make_filter_candidate(f_cfg):
    ftype = f_cfg.get("type")
    if ftype == "regex":
        field = f_cfg["field"]
        f_cfg["pattern"]
        replace = f_cfg.get('replace')
        if replace:
            pattern = re.compile(f_cfg["pattern"])
        else:
            return None
        return lambda r: pattern.sub(replace,str(r.get(field, "")))
    elif ftype == "paren_match":
        field = f_cfg["field"]
        
        return lambda r: gen_bracket_fix_candidate(str(r.get(field, "")))
    return None

def apply_filter(filter_func):
    global filtered_indices, current_index
    filtered_indices = [
        i for i, r in enumerate(store.records) if filter_func(r)
    ]
    current_index = 0
    refresh_record_view()

def clear_filter():
    global filtered_indices, current_index, filter_candidate
    filtered_indices = []
    current_index = 0
    filter_candidate = None
    refresh_record_view()

def get_visible_index(idx):
    """返回当前 idx 对应的实际 records 索引"""
    if filtered_indices:
        return filtered_indices[idx]
    return idx

def get_visible_count():
    return len(filtered_indices) if filtered_indices else len(store.records)

def update_record_status():
    total = get_visible_count()
    if total == 0:
        record_status_label.text = "无记录"
    elif ITEMS_PER_PAGE == 1:
        record_status_label.text = f"第 {current_index+1} 条 / 共 {total} 条"
    else:
        # current_index 是相对过滤后的索引
        record_status_label.text = f"第 {current_index+1}-{min(current_index+ITEMS_PER_PAGE,total)} 条 / 共 {total} 条"
# =============================
# Candidate Provider
# =============================

def get_candidate(rec: any, field_name: str) -> Dict[str, str]:
    result = {}
    if filter_candidate:
         result["过滤器"] = filter_candidate(rec)
    for name, candidate in candidates.items():
        rec_keyname = candidate.cfg.get('keyname')
        if rec_keyname and rec_keyname in rec:
            rec_key = rec[rec_keyname]
        else:
            rec_key = rec.get(KEYNAME)

        record = candidate.get(rec_key)
        if record and field_name in record:
            result[name] = record[field_name]
    return result

# =============================
# Diff Engine (character-level, no tokenization)
# =============================

def opcodes_for_diff(a: str, b: str,ignore_pattern = None) -> List[Tuple[str, int, int, int, int]]:
    sm = SequenceMatcher(a=a, b=b, autojunk=False)
    ops = sm.get_opcodes()
    
    if ignore_pattern is None:
        return ops
    new_ops = []
    ignore_re = re.compile(ignore_pattern)
    for tag, i1, i2, j1, j2 in sm.get_opcodes():
        ai, aj = i1, j1
        while ai < i2 or aj < j2:
            # -------- 1) 先吃掉两侧的忽略字符，标记为 equal
            start_ai, start_aj = ai, aj
            while ai < i2 and ignore_re.match(a[ai]):
                ai += 1
            while aj < j2 and ignore_re.match(b[aj]):
                aj += 1
            if ai > start_ai or aj > start_aj:
                new_ops.append(("equal", start_ai, ai, start_aj, aj))
                continue  # 继续下一轮（可能还剩不忽略的字符）

            # -------- 2) 处理剩下的不忽略字符
            if ai < i2 and aj < j2:
                new_ops.append((tag, ai, ai+1, aj, aj+1))
                ai += 1
                aj += 1
            elif ai < i2:
                new_ops.append(("delete", ai, ai+1, aj, aj))
                ai += 1
            elif aj < j2:
                new_ops.append(("insert", ai, ai, aj, aj+1))
                aj += 1
    return new_ops


def apply_single_chunk(a: str, b: str, opcode_index: int = None, action: str = 'auto', ignore_pattern=None) -> str:
    ops = opcodes_for_diff(a,b,ignore_pattern)
    parts: List[str] = []
    for idx, (tag, i1, i2, j1, j2) in enumerate(ops):
        if tag == 'equal':
            parts.append(a[i1:i2])
        elif not opcode_index or idx == opcode_index:
            if tag == 'insert':
                if action in ('insert', 'replace', 'auto'):
                    parts.append(b[j1:j2])
            elif tag == 'delete':
                if action in ('delete', 'auto'):
                    pass
                else:
                    parts.append(a[i1:i2])
            elif tag == 'replace':
                if action != 'delete':
                    parts.append(b[j1:j2])
        else:
            if tag in ('replace', 'delete'):
                parts.append(a[i1:i2])
    return ''.join(parts)

# =============================
# UI State & Styles
# =============================
current_index = 0

ui.add_head_html("""
    <style>
      .chunk { display:inline-block; margin: 0 1px; padding: 0 2px; border-radius: 4px; cursor: pointer;  white-space: pre-wrap; }
      .chunk.equal { background: transparent; color: inherit; cursor: default; }
      .chunk.insert { background-color: #d4f8d4; }
      .chunk.delete { background-color: #f8d4d4; text-decoration: line-through; }
      .muted { opacity: 0.7; }
      .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
      .flex-break { flex-basis: 100%; height: 0; }
      .newline-glyph { opacity: .6; }
      .config-btn { background-color: #eee; color: #333; padding: 4px 8px; border-radius: 4px; cursor: pointer; }
    </style>
""")

field_editors: Dict[Tuple[int,str], ui.element] = {}
field_diff_containers: Dict[Tuple[int,str], ui.element] = {}

# =============================
# Save & Autosave
# =============================

def save_now(_: object = None) -> None:
    store.save(OUTPUT_PATH)
    if store.dirty is False:
        ui.notify('已保存 ✔')


app.on_shutdown(save_now)

# =============================
# Navigation & Updates
# =============================

def set_current_index(new_index: int) -> None:
    global current_index
    max_idx = get_visible_count() - 1
    current_index = max(0, min(new_index, max_idx))
    refresh_record_view()


def update_field(index:int, field_name: str, value: str) -> None:
    store.set_field(index, field_name, value)
    if FIELD_MODES[field_name] == 'normal':
        render_diffs_for_field(index, field_name)
    else:
        render_compact_fields(compact_container)

# =============================
# Rendering helpers
# =============================

def _emit_text_with_breaks(text: str, cls: str, on_click=None, show_newline_glyph: bool = False):
    """Render text; whenever a "\n" is encountered, force a new flex line by inserting a flex-break.
    If show_newline_glyph is True, also render a small clickable glyph before breaking the line.
    """
    tokens = re.split(r"(\n)", text)
    for tok in tokens:
        if tok == "":
            continue
        if tok == "\n":
            if show_newline_glyph:
                el = ui.label('↵').classes(cls + ' newline-glyph')
                if on_click:
                    el.on('click', on_click)
            ui.html('').classes('flex-break')
        else:
            el = ui.label(tok).classes(cls)
            if on_click:
                el.on('click', on_click)

def get_ignore_pattern(field_name):
    
    if pattern := FIELD_MAP.get(field_name).get("ignore_pattern"):
        return pattern
    return None

def _render_diff_chunks(index:int, field_name: str, original: str, cand_text: str, size: str = 'normal'):
    ops = opcodes_for_diff(original, cand_text, get_ignore_pattern(field_name))
    with ui.row().classes('wrap items-start gap-1 ' + ('mono text-sm' if size == 'compact' else 'mono')):
        for idx, (tag, i1, i2, j1, j2) in enumerate(ops):
            if tag == 'equal':
                _emit_text_with_breaks(cand_text[j1:j2], 'chunk equal', None, show_newline_glyph=False)
            elif tag == 'delete':
                _emit_text_with_breaks(original[i1:i2] or '', 'chunk delete',
                                    on_click=lambda e, idx=idx: apply_chunk(index, field_name, cand_text, idx, 'delete'),
                                    show_newline_glyph=True)
            elif tag == 'insert':
                _emit_text_with_breaks(cand_text[j1:j2] or '', 'chunk insert',
                                    on_click=lambda e, idx=idx: apply_chunk(index, field_name, cand_text, idx, 'insert'),
                                    show_newline_glyph=True)
            elif tag == 'replace':
                _emit_text_with_breaks(original[i1:i2] or '', 'chunk delete',
                                    on_click=lambda e, idx=idx: apply_chunk(index, field_name, cand_text, idx, 'delete'),
                                    show_newline_glyph=True)
                _emit_text_with_breaks(cand_text[j1:j2] or '', 'chunk insert',
                                    on_click=lambda e, idx=idx: apply_chunk(index, field_name, cand_text, idx, 'replace'),
                                    show_newline_glyph=True)
    return

def render_single_candidate(index:int, field_name: str, source: str, cand_text: str, container: ui.element):
    container.clear()
    original = store.records[index][field_name]
    with container:
        with ui.row().classes('items-center justify-between w-full'):
            ui.label(f'候选来源: {source}').classes('text-primary')
            with ui.row().classes('gap-2'):
                ui.button('应用全部变化', on_click=lambda: apply_all_from_candidate(index, field_name, cand_text)).props('dense flat')
                ui.button('替换文本', on_click=lambda: replace_from_candidate(index, field_name, cand_text)).props('dense flat')
                #ui.button('恢复为当前文本', on_click=lambda: refresh_record_view()).props('dense flat')
        _render_diff_chunks(index, field_name, original, cand_text, size='normal')


def render_diffs_for_field(index:int, field_name: str) -> None:
    # 确保存在容器
    if (index, field_name) not in field_diff_containers:
        field_diff_containers[(index,field_name)] = ui.column().classes('w-full')
    field_container = field_diff_containers[(index,field_name)]
    field_container.clear()
    rec = store.records[index]
    candidates_dict = get_candidate(rec, field_name)
    if not candidates_dict:
        with field_container:
            ui.label('无候选数据').classes('muted')
        return
    with field_container:
        for source, cand_text in candidates_dict.items():
            with ui.card():
                inner = ui.column()
                render_single_candidate(index, field_name, source, cand_text, inner)

def render_compact_fields(container: ui.element):
    container.clear()
    compact_fields = [f for f, mode in FIELD_MODES.items() if mode == 'compact']
    if not compact_fields:
        return
    for offset in range(ITEMS_PER_PAGE):
        if current_index + offset >= get_visible_count():
            break
        real_idx = get_visible_index(current_index+offset)
        rec = store.records[real_idx]
        sources: Dict[str, Dict[str, str]] = {}
        for f in compact_fields:
            for src, text in get_candidate(rec, f).items():
                sources.setdefault(src, {})[f] = text

        with container:
            with ui.row().classes('items-start gap-6'):
                for f in compact_fields:
                    inp = ui.input(value=rec.get(f, ''), placeholder=f'输入/编辑 {f} 文本…').classes('w-[280px] mono')
                    inp.on('blur', lambda e, field=f, idx=real_idx: update_field(idx, field, e.sender.value))
                    field_editors[(real_idx,f)] = inp

            for src, values in sources.items():
                with ui.row().classes('items-start gap-6'):
                    for f in compact_fields:
                        cand_text = values.get(f, '')
                        col = ui.column().classes('w-[280px]')
                        with col:
                            ui.label(f'{src}').classes('text-xs text-primary')
                            _render_diff_chunks(real_idx, f, rec.get(f, ''), cand_text, size='compact')
                            ui.button('应用全部', on_click=lambda f=f, t=cand_text, idx=real_idx: apply_all_from_candidate(idx, f, t)).props('dense flat')

# =============================
# Apply actions
# =============================

def apply_chunk(index:int, field_name: str, cand_text: str, opcode_index: int, action: str) -> None:
    original = store.records[index][field_name]
    new_value = apply_single_chunk(original, cand_text, opcode_index, action, get_ignore_pattern(field_name))
    store.set_field(index, field_name, new_value)
    field_editors[(index,field_name)].value = new_value
    if FIELD_MODES[field_name] == 'normal':
        render_diffs_for_field(index, field_name)
    else:
        render_compact_fields(compact_container)


def apply_all_from_candidate(index:int, field_name: str, cand_text: str) -> None:
    original = store.records[index][field_name]
    new_value = apply_single_chunk(original, cand_text, None, 'auto', get_ignore_pattern(field_name))
    store.set_field(index, field_name, new_value)
    field_editors[(index,field_name)].value = new_value
    if FIELD_MODES[field_name] == 'normal':
        render_diffs_for_field(index, field_name)
    else:
        render_compact_fields(compact_container)

def replace_from_candidate(index:int, field_name: str, cand_text: str) -> None:
    store.set_field(index, field_name, cand_text)
    field_editors[(index,field_name)].value = cand_text
    if FIELD_MODES[field_name] == 'normal':
        render_diffs_for_field(index, field_name)
    else:
        render_compact_fields(compact_container)

# =============================
# PDF Image Provider
# =============================

def merge_image(pdf, positions, direction="vertical", scale = False):
    """
    pdf: fitz.Document 对象
    positions: [{'page': 0, 'bbox': (x0, y0, x1, y1)}, ...]
    direction: "vertical" or "horizontal"
    """
    crops = []
    
    for pos in positions:
        page = pdf[pos["page"]-1]
        # 获取页面的所有图片
        img_list = page.get_images(full=True)
        if not img_list:
            continue
        
        # 取第一个图片
        xref = img_list[0][0]
        base_image = pdf.extract_image(xref)
        img_data = base_image["image"]
        img = Image.open(io.BytesIO(img_data))
        img = ImageOps.invert(img)
        
        # 注意：PDF 页面和图像坐标可能不一致，通常需要用 matrix 转换
        # 这里假设第一个图像覆盖整个页面（常见情况：扫描件）
        # 所以直接按照 bbox 比例在图像上裁剪
        page_rect = page.rect
        x0, y0, x1, y1 = pos["bbox"]
        
        # 转换 bbox 到图像坐标
        w, h = img.size
        if scale:
            crop_box = (
                int(x0 / page_rect.width * w),
                int(y0 / page_rect.height * h),
                int(x1 / page_rect.width * w),
                int(y1 / page_rect.height * h),
            )
        else:
            crop_box = (x0, y0, x1, y1)
        crop_img = img.crop(crop_box)
        crops.append(crop_img)

    # 拼接图片
    if not crops:
        return None
    
    if direction == "vertical":
        total_width = max(img.width for img in crops)
        total_height = sum(img.height for img in crops)
        result = Image.new("RGB", (total_width, total_height), (255, 255, 255))
        
        y_offset = 0
        for img in crops:
            result.paste(img, (0, y_offset))
            y_offset += img.height
    else:  # horizontal
        total_width = sum(img.width for img in crops)
        total_height = max(img.height for img in crops)
        result = Image.new("RGB", (total_width, total_height), (255, 255, 255))
        
        x_offset = 0
        for img in crops:
            result.paste(img, (x_offset, 0))
            x_offset += img.width
    
    return result

@app.get('/get_word_image')
def get_word_image(index: int, field: str = ''):
    if not image_source and not pdf_doc:
        return Response(status_code=404)
    if index < 0 or index >= len(store.records):
        return Response(status_code=404)
    rec = store.records[index]
    page_key = field + '_page' if field else 'page'
    bbox_key = field + '_bbox' if field else 'bbox'
    image_field = rec
    image_data = image_source.get_image(rec[KEYNAME])
    if image_data:
        return Response(content=image_data, media_type='image/png')
    else:
        return Response(status_code=404)
        
    

# =============================
# Layout with Config Loader
# =============================

def build_header():
    global main_column, record_status_label
    main_column = ui.column().classes('w-full')
    with ui.header().classes('items-center justify-between') as header:
        ui.label(TITLE).classes('text-h6')
        with ui.row().classes('items-center gap-2'):
            filter_options = [("无过滤", None, None)] + [
            (f_cfg["title"], make_filter(f_cfg), make_filter_candidate(f_cfg)) for f_cfg in CONFIG.get("filters", [])
        ]
            def on_filter_change(e):
                global filter_candidate
                idx = int(e.value)
                filter_candidate = filter_options[idx][2]
                if idx == 0:
                    clear_filter()
                else:
                    f = filter_options[idx][1]
                    apply_filter(f)
            select_filter = ui.select({i: name for i, (name, _, _) in enumerate(filter_options)},
                                    label="过滤器", value=0,on_change=on_filter_change)
            
            ui.html('<input type="file" id="fileInput" style="display:none" onchange="window.loadConfigFromFile(this)" />')
            prev_btn = ui.button('⟵ 后退', on_click=lambda: set_current_index(current_index - ITEMS_PER_PAGE)).props('dense')
            next_btn = ui.button('前进 ⟶', on_click=lambda: set_current_index(current_index + ITEMS_PER_PAGE)).props('dense')
            record_status_label = ui.label().classes('text-sm')
            if store.records:
                jump_field_select = ui.select(
                    {f: f for f in ['_index',KEYNAME] + [k for k in CONFIG.get("jump_fields",[]) if k != KEYNAME]},
                    value=KEYNAME,
                    label="跳转字段"
                ).props('dense').classes('w-28')

                jump_value_input = ui.input(placeholder='输入值…').props('dense').classes('w-32')

                def goto():
                    field = jump_field_select.value
                    if field == '_index':
                        try:
                            value = int(jump_value_input.value) - 1
                        except ValueError:
                            ui.notify('请输入有效的数字索引')
                            return
                        if value < 0 or value >= get_visible_count():
                            ui.notify('索引超出范围')
                            return
                        set_current_index(value)
                        return
                    else:
                        value = jump_value_input.value
                        found_idx = None
                        for i in range(get_visible_count()):
                            real_idx = get_visible_index(i)
                            rec = store.records[real_idx]
                            if str(rec.get(field, '')) == value:
                                found_idx = i
                                break
                        if found_idx is None:
                            ui.notify(f'找不到 {field}={value}')
                            return
                        set_current_index(found_idx)
                jump_value_input.on('keydown.enter', lambda e: goto())
                ui.button('跳转', on_click=goto).props('dense')
            ui.separator().props('vertical')
            ui.button('保存', on_click=save_now).props('dense')


def build_layout():
    global compact_container, main_column, store
    

    with main_column:
        compact_container = ui.column().classes('w-full')
        for offset in range(ITEMS_PER_PAGE):
            if current_index + offset >= get_visible_count():
                break
            real_idx = get_visible_index(current_index+offset)
            rec = store.records[real_idx]
            ui.label(f"{KEYNAME}={rec.get(KEYNAME)}").classes('text-h6 text-primary')
            if image_source and image_source.get_position(rec.get(KEYNAME)):
                ui.image(f"/get_word_image?index={real_idx}").style("max-width: 400px; height: auto;")
            for f in FIELDS:
                if FIELD_MODES[f] == 'normal':
                    if FIELD_PDF_KEYS.get(f) != None:
                        ui.image(f"/get_word_image?index={real_idx}&field={FIELD_PDF_KEYS[f]}")
                    ui.label(FIELD_LABELS[f]).classes('text-subtitle1')
                    ta = ui.textarea(value=rec.get(f, ''), placeholder=f'输入/编辑 {f} 文本…').classes('w-full mono')
                    ta.on('blur', lambda e, field_name=f, idx=real_idx: update_field(idx, field_name, e.sender.value))
                    field_editors[(real_idx,f)] = ta
                    field_diff_containers[(real_idx,f)] = ui.column().classes('w-full')
                    render_diffs_for_field(real_idx,f)
        render_compact_fields(compact_container)
        update_record_status()
# =============================
# Refresh
def refresh_record_view() -> None:
    main_column.clear()
    field_diff_containers.clear()
    field_editors.clear()
    with main_column:
        build_layout()


# =============================
# Init
# =============================

load_config(CONFIG_PATH)
build_header()
build_layout()
refresh_record_view()

ui.run(native=False, title=TITLE)