"""
HWP 97 (V3.00) 문서 구조 파싱

HWP 97 (V3.00) 파일은 CFB(Compound File Binary) 포맷이 아닌 단순 바이너리 파일입니다.
파일 구조:
  [파일 인식 정보 30] [문서 정보 128] [문서 요약 1008] [정보 블록 n]
  [--- 이하 압축 가능 ---]
  [글꼴 이름] [스타일] [문단 리스트] [추가 정보 블록 #1]
  [추가 정보 블록 #2 (미압축)]

참고: 한글문서파일형식97분석보고서, Section 3 (전체 구조)
"""

import io
import struct
import zlib
from dataclasses import dataclass, field
from typing import Any, BinaryIO, List, Optional, Union

from .char_paragraph import HChar, HCharType, Paragraph
from .constants import (
    DOCUMENT_INFO_SIZE,
    DOCUMENT_SUMMARY_SIZE,
    FILE_SIGNATURE_SIZE,
    FONT_LANG_COUNT,
    FONT_NAME_SIZE,
    INFO_BLOCK_HEADER_SIZE,
    STYLE_ENTRY_SIZE,
    SpecialCharCode,
)
from .models import (
    CharShape,
    DocumentInfo,
    DocumentSummary,
    ExtraBlock,
    FileHeader,
    InfoBlock,
    StyleEntry,
)
from .parsed_elements import ParsedParagraph, ParsedTable, TableCell


# ---------------------------------------------------------------------------
# 글꼴 이름 파싱
# ---------------------------------------------------------------------------


def _parse_font_names(stream: BinaryIO) -> List[List[str]]:
    """글꼴 이름 파싱 (7개 언어 반복).

    각 언어: word(개수) + kchar[40] × 개수
    반환: [[한글 글꼴들], [영문 글꼴들], ..., [사용자 글꼴들]]
    """
    all_fonts: List[List[str]] = []
    for _ in range(FONT_LANG_COUNT):
        count_raw = stream.read(2)
        if len(count_raw) < 2:
            break
        count = struct.unpack_from("<H", count_raw)[0]
        lang_fonts: List[str] = []
        for _ in range(count):
            name_raw = stream.read(FONT_NAME_SIZE)
            if len(name_raw) < FONT_NAME_SIZE:
                break
            null_pos = name_raw.find(b"\x00")
            name_bytes = name_raw[:null_pos] if null_pos >= 0 else name_raw
            # HWP 97 (V3.00) 글꼴 이름은 Johab(조합형) 인코딩 (EUC-KR 아님)
            lang_fonts.append(name_bytes.decode("johab", errors="replace"))
        all_fonts.append(lang_fonts)
    return all_fonts


# ---------------------------------------------------------------------------
# 스타일 파싱
# ---------------------------------------------------------------------------


def _parse_styles(stream: BinaryIO) -> List[StyleEntry]:
    """스타일 파싱 (개수 word + StyleEntry × 개수)"""
    count_raw = stream.read(2)
    if len(count_raw) < 2:
        return []
    count = struct.unpack_from("<H", count_raw)[0]
    styles: List[StyleEntry] = []
    for _ in range(count):
        entry_raw = stream.read(STYLE_ENTRY_SIZE)
        if len(entry_raw) < STYLE_ENTRY_SIZE:
            break
        styles.append(StyleEntry.from_bytes(entry_raw))
    return styles


# ---------------------------------------------------------------------------
# 표 정보 파싱 (특수 문자 코드 10의 extra_data)
# ---------------------------------------------------------------------------


def _parse_table_from_extra(extra_data: bytes) -> Optional[ParsedTable]:
    """특수 문자 코드 10 (BOX)의 extra_data에서 표 정보 파싱.

    스펙: Section 10.6
    표 정보 구조 (82 bytes 실측, 스펙은 84 bytes 기재):
      offset 8:  byte - 기준 위치
      offset 9:  byte - 그림 피함
      offset 10: shunit - 가로 위치
      offset 12: shunit - 세로 위치
      offset 14: word - 기타 옵션 (bit4: 하이퍼텍스트 여부)
      offset 76: word - 박스 종류 (0=표, 1=텍스트박스, 2=수식, 3=버튼)  [실측]
      offset 78: word - 셀 개수 (표일 때 셀의 개수, 이외는 늘 1)  [실측]
    """
    if len(extra_data) < 82:
        return None

    # extra_data는 size(4) + 실제 내용이므로 offset 4부터 표 정보
    info_offset = 4  # 앞 4바이트는 size_raw

    if len(extra_data) < info_offset + 82:
        return None

    d = extra_data[info_offset:]

    x = struct.unpack_from("<h", d, 10)[0] if len(d) >= 14 else 0
    y = struct.unpack_from("<h", d, 12)[0] if len(d) >= 14 else 0
    options = struct.unpack_from("<H", d, 14)[0] if len(d) >= 16 else 0
    box_type = struct.unpack_from("<H", d, 76)[0] if len(d) >= 78 else 0
    cell_count = struct.unpack_from("<H", d, 78)[0] if len(d) >= 80 else 0
    is_hyperlink = bool(options & 0x10)  # bit4 = 하이퍼텍스트

    return ParsedTable(
        box_type=box_type,
        rows=0,
        cols=cell_count,  # 실제 row/col 은 셀 파싱 후 재계산
        x=x,
        y=y,
        is_hyperlink=is_hyperlink,
    )


# ---------------------------------------------------------------------------
# 문단 리스트 파싱 (재귀 가능)
# ---------------------------------------------------------------------------


def _parse_box_after_ident(stream: BinaryIO) -> Optional[ParsedTable]:
    """코드 10 식별정보 이후의 표/텍스트박스 내용 파싱.

    표 정보 (84 bytes) → 셀 정보 (27 bytes × 셀수) → 각 셀 문단리스트 × 셀수 → 캡션 문단리스트
    박스 종류: 0=표, 1=텍스트박스, 2=수식, 3=버튼.
    스펙 Section 10.6: 스펙 offset 78=박스 종류, offset 80=셀 개수이나
    실측으로는 offset 76=박스 종류, offset 78=셀 개수 (스펙보다 2바이트 앞).
    cell_count가 0이면 (텍스트박스/수식 등) 문단리스트 1개만 파싱.

    box_type > 3 이거나 cell_count > 256이면 렌더링 캐시 데이터로 간주하여 skip.

    Args:
        stream: 식별정보(8바이트) 이후 스트림

    Returns:
        ParsedTable (box_type=0이면 표, 이외는 텍스트박스 등), 오류 시 None
    """
    pos_before = stream.tell()
    table_info = stream.read(84)
    if len(table_info) < 84:
        return None

    box_type = struct.unpack_from("<H", table_info, 78)[0]
    cell_count = struct.unpack_from("<H", table_info, 80)[0]

    # 비정상 값이면 렌더링 캐시로 간주 → 위치 복구 후 None 반환
    if box_type > 3 or cell_count > 256:
        stream.seek(pos_before)
        return None

    # 셀 정보 (27 bytes × 셀수) 읽어 x/y 위치로 row/col 역산
    # 셀 정보 layout: [0~1: 예약(2)] [2~3: color(2)] [4~5: x(2)] [6~7: y(2)] ...
    CellInfo = tuple  # (x, y)
    cell_positions: List[CellInfo] = []
    for _ in range(cell_count):
        cell_raw = stream.read(27)
        if len(cell_raw) >= 8:
            x = struct.unpack_from("<H", cell_raw, 4)[0]
            y = struct.unpack_from("<H", cell_raw, 6)[0]
            cell_positions.append((x, y))
        else:
            cell_positions.append((0, 0))

    # x/y 좌표를 정렬하여 행/열 인덱스 매핑
    unique_ys = sorted(set(p[1] for p in cell_positions))
    unique_xs = sorted(set(p[0] for p in cell_positions))
    y_to_row = {y: i for i, y in enumerate(unique_ys)}
    x_to_col = {x: i for i, x in enumerate(unique_xs)}

    # 행/열 수
    max_row = len(unique_ys) if unique_ys else 1
    max_col = len(unique_xs) if unique_xs else 1

    # 셀 개수가 0이면 텍스트박스/수식 등: 문단리스트 1개만 존재
    actual_count = cell_count if cell_count > 0 else 1

    # 각 셀의 문단리스트 파싱 (재귀)
    cells: List[TableCell] = []
    for i in range(actual_count):
        cell_paras = _parse_paragraph_list(stream)
        if i < len(cell_positions):
            x, y = cell_positions[i]
            row_idx = y_to_row.get(y, 0)
            col_idx = x_to_col.get(x, 0)
        else:
            row_idx, col_idx = 0, i
        # ParsedParagraph만 셀에 담음 (중첩 표는 텍스트 평탄화)
        flat_paras: List[ParsedParagraph] = []
        for item in cell_paras:
            if isinstance(item, ParsedParagraph):
                flat_paras.append(item)
            elif isinstance(item, ParsedTable):
                # 중첩 표: 셀 텍스트를 탭 구분 문자열로 평탄화
                for trow in item.cell_texts:
                    text = "\t".join(trow)
                    if text.strip():
                        flat_paras.append(ParsedParagraph(text=text))
        cells.append(TableCell(row=row_idx, col=col_idx, paragraphs=flat_paras))

    # 캡션 문단리스트: 존재 여부를 peek으로 검증 후 파싱
    # follow_prev(1) + char_count(2) + line_count(2) + has_cs(1) = 6 바이트
    cap_peek = stream.read(6)
    if len(cap_peek) == 6:
        fp = cap_peek[0]
        cc = struct.unpack_from("<H", cap_peek, 1)[0]
        lc = struct.unpack_from("<H", cap_peek, 3)[0]
        hcs = cap_peek[5]
        # 유효한 para info 헤더 조건
        valid_cap = fp in (0, 1) and cc <= _MAX_CC and lc <= 200 and hcs <= 1
        if valid_cap:
            # 6바이트를 되돌리고 _parse_paragraph_list로 소비
            stream.seek(-6, 1)
            _parse_paragraph_list(stream)  # 캡션은 수집하지 않음
        # valid_cap이 False면 렌더링 캐시 데이터 → 캡션 없음, 6바이트 소비 후 복귀
        # (이미 6바이트를 read()로 소비했으므로 복구 없이 그대로 진행)

    return ParsedTable(
        box_type=box_type,
        rows=max_row,
        cols=max_col,
        cells=cells,
    )


def _parse_picture_after_ident(stream: BinaryIO) -> None:
    """코드 11 식별정보 이후의 그림 내용 skip.

    스펙 Section 10.7:
    그림 정보 (348 + n bytes) → 캡션 문단리스트

    Args:
        stream: 식별정보(8바이트) 이후 스트림
    """
    pic_info = stream.read(348)
    if len(pic_info) < 348:
        return
    n = struct.unpack_from("<I", pic_info, 0)[0]  # 추가 정보 길이
    stream.read(n)
    # 캡션 문단리스트 skip
    _parse_paragraph_list(stream)


def _skip_para_list_special(stream: BinaryIO, code: int) -> None:
    """문단리스트 포함 특수 코드(15,16,17)의 식별정보 이후 내용 skip.

    스펙:
    - 15(숨은설명): 예약(8) + 문단리스트
    - 16(머리말/꼬리말): 헤더(10) + 문단리스트
    - 17(각주/미주): 헤더(14) + 문단리스트
    """
    header_sizes = {15: 8, 16: 10, 17: 14}
    header_size = header_sizes.get(code, 0)
    stream.read(header_size)
    _parse_paragraph_list(stream)


def _skip_line_after_ident(stream: BinaryIO) -> None:
    """코드 14(선) 식별정보 이후의 선 정보 skip.

    스펙 Section 10.8: 선 정보 (84 bytes), 문단리스트 없음.
    """
    stream.read(84)


def _parse_paragraph_list(
    stream: BinaryIO,
) -> List[Union[ParsedParagraph, ParsedTable]]:
    """문단 리스트를 읽어 ParsedParagraph/ParsedTable 혼합 리스트 반환.

    빈 문단(char_count=0)이 나오면 리스트 종료.
    char_count > _MAX_CC이면 렌더링 캐시 진입으로 판단하여 즉시 종료.
    특수 문자(코드 10/11/14/15/16/17) 발견 시 스트림 직후 위치에서
    추가 데이터를 읽어 태그 체인 방식으로 재귀 파싱합니다.
    코드 10(표/텍스트박스)은 ParsedTable 으로 반환됩니다.
    """
    paragraphs: List[Union[ParsedParagraph, ParsedTable]] = []

    while True:
        pos_before = stream.tell()
        para = Paragraph.read_from_stream(stream)
        if para is None:
            break
        if para.is_empty:
            break

        # 렌더링 캐시 진입 감지 (두 가지 조건)
        # 1) char_count가 비현실적으로 크면 중단
        # 2) line_count=0이면서 char_count>0 → 렌더링 캐시 para (정상 para는 반드시 lc>=1)
        if para.info.char_count > _MAX_CC:
            stream.seek(pos_before)  # 잘못 읽은 위치 복구
            break
        if para.info.char_count > 0 and para.info.line_count == 0:
            stream.seek(pos_before)  # 잘못 읽은 위치 복구
            break

        text = para.to_string()
        repr_cs = para.info.repr_char_shape

        # 글자 모양 정보 구성
        char_shapes: List[tuple] = []
        for idx, cs in para.char_shape_map:
            if cs is not None:
                char_shapes.append((idx, cs))

        paragraphs.append(
            ParsedParagraph(
                text=text,
                char_shape=repr_cs,
                char_shapes=char_shapes,
                is_page_break=para.info.is_page_break,
                is_column_break=para.info.is_column_break,
                style_index=para.info.style_index,
            )
        )

        # 특수 문자(코드 10/11/14/15/16/17) 처리:
        # read_hchars에서 식별 정보(8B)만 소비하고 종료했으므로
        # 스트림 현재 위치가 해당 객체의 추가 데이터 시작점.
        should_end_list = False
        for hc in para.chars or []:
            if hc.char_type.value == "special":
                code = hc.code
                if code == 10:  # 표/텍스트박스/수식
                    parsed_table = _parse_box_after_ident(stream)
                    if parsed_table is not None:
                        paragraphs.append(parsed_table)
                    # 표 파싱 후 이 para list는 종료: 다음 본문은 별도 para list
                    should_end_list = True
                elif code == 11:  # 그림
                    _parse_picture_after_ident(stream)
                elif code == 14:  # 선
                    _skip_line_after_ident(stream)
                elif code in (15, 16, 17):  # 숨은설명/머리말꼬리말/각주미주
                    _skip_para_list_special(stream, code)
                break  # 문단당 인라인 객체는 1개
        if should_end_list:
            break

    return paragraphs


# ---------------------------------------------------------------------------
# 렌더링 캐시 감지 임계값
# ---------------------------------------------------------------------------

_MAX_CC = 300  # 이 이상의 char_count는 렌더링 캐시 데이터로 간주


def _find_next_para_list_start(body: bytes, start: int, max_scan: int = 10000) -> int:
    """body[start:start+max_scan] 범위에서 유효한 para list 시작 오프셋을 탐색.

    para info 헤더 조건:
      - follow_prev_shape (1byte): 0 또는 1
      - char_count (2byte, LE): 1 ~ _MAX_CC
      - line_count (2byte, LE): 1 ~ 200
      - has_char_shape (1byte): 0 또는 1

    Returns:
        유효한 오프셋 (절대 인덱스), 없으면 -1
    """
    end = min(start + max_scan, len(body) - 43)
    pos = start
    while pos < end:
        follow = body[pos]
        if follow > 1:
            pos += 1
            continue
        char_count = struct.unpack_from("<H", body, pos + 1)[0]
        if not (1 <= char_count <= _MAX_CC):
            pos += 1
            continue
        line_count = struct.unpack_from("<H", body, pos + 3)[0]
        if not (1 <= line_count <= 200):
            pos += 1
            continue
        has_cs = body[pos + 5]
        if has_cs > 1:
            pos += 1
            continue
        return pos
    return -1


def _is_valid_text(text: str, char_shape: Optional[Any] = None) -> bool:
    """파싱된 텍스트가 유효한 본문 텍스트인지 검증.

    다음 경우 무효로 판정:
    1. U+FFFD(대체 문자) 포함: 잘못된 Johab 디코딩
    2. 텍스트 길이 > 3이고 개행 비율 50% 초과: 렌더링 캐시 잡음
    3. CharShape.attr == 255 (모든 비트 1): 렌더링 캐시 데이터의 garbage CharShape
    """
    if "\ufffd" in text:
        return False
    # 개행 비율 검사: 실제 텍스트보다 \n이 절반 이상이면 쓰레기 데이터
    if len(text) > 3:
        newline_ratio = text.count("\n") / len(text)
        if newline_ratio > 0.5:
            return False
    # CharShape 유효성: attr=255는 렌더링 캐시 데이터
    if char_shape is not None and getattr(char_shape, "attr", 0) == 255:
        return False
    return True


def _parse_all_body_lists(body_stream: BinaryIO) -> List[Union[ParsedParagraph, ParsedTable]]:
    """body_stream의 현재 위치부터 root para list + 이후 모든 para list 파싱.

    HWP 97 (V3.00) body는:
      1. root para list (스타일 직후, 1페이지 포함)
      2. 렌더링 캐시 데이터 (가변 크기, 건너뜀)
      3. 이후 추가 para list들 (나머지 페이지들)

    para list 종료 마커(char_count=0) 이후 나오는 렌더링 캐시 데이터를
    유효한 para info 헤더 패턴으로 스캔하여 다음 para list 시작점을 탐색합니다.
    """
    root_start = body_stream.tell()
    body_bytes = body_stream.read()  # 나머지 전체 (root_start 이후)

    all_paragraphs: List[Union[ParsedParagraph, ParsedTable]] = []

    # root para list 파싱
    root_stream = io.BytesIO(body_bytes)
    root_paras = _parse_paragraph_list(root_stream)
    for para in root_paras:
        if isinstance(para, ParsedParagraph):
            if _is_valid_text(para.text, para.char_shape):
                all_paragraphs.append(para)
        else:
            all_paragraphs.append(para)
    current_end = root_stream.tell()  # body_bytes 내 상대 위치

    # 순차적으로 다음 para list를 스캔하여 파싱
    # 최대 반복 수를 제한하여 무한루프 방지
    MAX_ITER = 2000
    iteration = 0
    while iteration < MAX_ITER and current_end < len(body_bytes) - 43:
        iteration += 1
        next_start = _find_next_para_list_start(body_bytes, current_end, max_scan=20000)
        if next_start < 0:
            # 20KB 범위에서 못 찾으면 큰 단위로 건너뜀
            current_end += 20000
            continue

        seg_stream = io.BytesIO(body_bytes[next_start:])
        seg_paras = _parse_paragraph_list(seg_stream)
        seg_end = seg_stream.tell()

        if seg_end <= 0 or next_start + seg_end <= current_end:
            # 진전이 없으면 1바이트 전진
            current_end = next_start + 1
            continue

        # 유효한 텍스트만 포함된 문단만 추가
        for para in seg_paras:
            if isinstance(para, ParsedParagraph):
                if _is_valid_text(para.text, para.char_shape):
                    all_paragraphs.append(para)
            else:
                all_paragraphs.append(para)

        current_end = next_start + seg_end

    return all_paragraphs


# ---------------------------------------------------------------------------
# HWP 97 (V3.00) 파일 최상위 구조
# ---------------------------------------------------------------------------


@dataclass
class Hwp97File:
    """HWP 97 (V3.00) 파일 파싱 결과

    Attributes:
        file_header: 파일 인식 정보
        doc_info: 문서 정보
        doc_summary: 문서 요약
        info_blocks: 정보 블록 리스트 (미압축)
        font_names: 글꼴 이름 (7개 언어)
        styles: 스타일 리스트
        paragraphs: 문서 본문 문단 리스트
        extra_blocks: 추가 정보 블록 #1 리스트
        extra_blocks2: 추가 정보 블록 #2 리스트
    """

    file_header: FileHeader
    doc_info: DocumentInfo
    doc_summary: DocumentSummary
    info_blocks: List[InfoBlock] = field(default_factory=list)
    font_names: List[List[str]] = field(default_factory=list)
    styles: List[StyleEntry] = field(default_factory=list)
    paragraphs: List[Union[ParsedParagraph, ParsedTable]] = field(default_factory=list)
    extra_blocks: List[ExtraBlock] = field(default_factory=list)
    extra_blocks2: List[ExtraBlock] = field(default_factory=list)

    @classmethod
    def from_file(cls, file_path: str) -> "Hwp97File":
        with open(file_path, "rb") as f:
            return cls.from_bytes(f.read())

    @classmethod
    def from_bytes(cls, data: bytes) -> "Hwp97File":
        stream = io.BytesIO(data)

        # 1. 파일 인식 정보 (30 bytes)
        file_header = FileHeader.from_stream(stream)

        # 2. 문서 정보 (128 bytes)
        doc_info = DocumentInfo.from_bytes(stream.read(DOCUMENT_INFO_SIZE))

        # 3. 문서 요약 (1008 bytes)
        doc_summary = DocumentSummary.from_bytes(stream.read(DOCUMENT_SUMMARY_SIZE))

        # 4. 정보 블록 (미압축, info_block_size 바이트)
        info_blocks: List[InfoBlock] = []
        if doc_info.info_block_size > 0:
            ib_data = stream.read(doc_info.info_block_size)
            ib_stream = io.BytesIO(ib_data)
            while True:
                ib = InfoBlock.read_from_stream(ib_stream)
                if ib is None:
                    break
                info_blocks.append(ib)

        # 5. 압축 영역 처리 (글꼴 ~ 추가 정보 블록 #1)
        remaining = stream.read()
        if doc_info.compressed and remaining:
            # gzip(deflate) 압축 해제 (wbits=-15: raw deflate)
            decompressed = zlib.decompress(remaining, -zlib.MAX_WBITS)
            body_stream = io.BytesIO(decompressed)
        else:
            body_stream = io.BytesIO(remaining)

        # 6. 글꼴 이름
        font_names = _parse_font_names(body_stream)

        # 7. 스타일
        styles = _parse_styles(body_stream)

        # 8. 문단 리스트 (태그 체인 방식: 특수 코드 10/11/14/15/16/17 재귀 파싱)
        #    root para list 파싱 후 body 나머지 영역의 추가 para list들도 순차 파싱
        paragraphs = _parse_all_body_lists(body_stream)

        # 9. 추가 정보 블록 #1 (압축 영역 끝부분)
        extra_blocks: List[ExtraBlock] = []
        while True:
            eb = ExtraBlock.read_from_stream(body_stream)
            if eb is None:
                break
            extra_blocks.append(eb)

        # 10. 추가 정보 블록 #2 (미압축, 원본 스트림 끝부분)
        # 압축 파일이면 extra_blocks2는 없을 수도 있음 (글 96 이후 추가)
        extra_blocks2: List[ExtraBlock] = []

        return cls(
            file_header=file_header,
            doc_info=doc_info,
            doc_summary=doc_summary,
            info_blocks=info_blocks,
            font_names=font_names,
            styles=styles,
            paragraphs=paragraphs,
            extra_blocks=extra_blocks,
            extra_blocks2=extra_blocks2,
        )

    def get_font_name(self, lang_idx: int, font_idx: int) -> str:
        """글꼴 이름 조회 (lang_idx: 0=한글, 1=영문, ...)"""
        if lang_idx < len(self.font_names) and font_idx < len(self.font_names[lang_idx]):
            return self.font_names[lang_idx][font_idx]
        return ""

    def get_style_name(self, style_idx: int) -> str:
        """스타일 이름 조회"""
        if style_idx < len(self.styles):
            return self.styles[style_idx].name_str
        return ""
