"""Deterministic, bounded parser for uploaded Booking Excel workbooks."""

from __future__ import annotations

import hashlib
import json
import re
import unicodedata
import zipfile
from dataclasses import dataclass
from io import BytesIO
from pathlib import PurePosixPath
from typing import Iterable, Mapping, Optional


PROCESSOR_NAME = "booking-excel-importer"
PROCESSOR_VERSION = "2.1.0"
RESULT_SCHEMA_VERSION = "1.0"
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
MAX_ARCHIVE_BYTES = 160 * 1024 * 1024
MAX_ARCHIVE_ENTRIES = 4096
MAX_WORKSHEETS = 64
MAX_HEADER_ROWS = 20
MAX_WORKSHEET_COLUMNS = 256
MAX_WORKSHEET_ROWS = 100_000
MAX_SOURCE_ROWS = 50_000
MAX_EXTRACTED_ITEMS = 100_000
MAX_TOUR_CODE_LENGTH = 128
MAX_HOTEL_TEXT_LENGTH = 8192
MAX_ROOM_LABEL_LENGTH = 256

REVIEW_CONFIRMED = "confirmed"
REVIEW_PENDING = "pending"
PENDING_ROOM_TYPE_LABEL = "待人工确认"


class BookingExcelError(ValueError):
    """A stable, user-safe workbook validation failure."""

    def __init__(self, code: str, safe_message: str, status: int = 422):
        super().__init__(safe_message)
        self.code = code
        self.safe_message = safe_message
        self.status = status


@dataclass(frozen=True)
class ExtractedRoomItem:
    item_no: int
    room_type_raw: str
    room_type_code: Optional[str]
    quantity: int
    review_status: str
    source_fragment: str

    @property
    def needs_review(self) -> bool:
        return self.review_status == REVIEW_PENDING

    def canonical_source(self) -> Mapping[str, object]:
        return {
            "item_no": self.item_no,
            "room_type_raw": self.room_type_raw,
            "room_type_code": self.room_type_code,
            "quantity": self.quantity,
            "review_status": self.review_status,
            "source_fragment": self.source_fragment,
        }

    def to_dict(self) -> dict[str, object]:
        return dict(self.canonical_source())


@dataclass(frozen=True)
class ExcelRow:
    worksheet: str
    row_no: int
    group_code_raw: str
    hotel_raw: str
    room_items: tuple[ExtractedRoomItem, ...]

    @property
    def group_code_key(self) -> str:
        return self.group_code_raw.upper()

    @property
    def type_of_room_raw(self) -> str:
        return self.hotel_raw

    @property
    def no_of_rooms(self) -> int:
        return sum(item.quantity for item in self.room_items)

    @property
    def pending_item_count(self) -> int:
        return sum(1 for item in self.room_items if item.needs_review)

    @property
    def confirmed_room_quantity(self) -> int:
        return sum(
            item.quantity for item in self.room_items if not item.needs_review
        )

    def canonical_source(self) -> Mapping[str, object]:
        return {
            "worksheet": self.worksheet,
            "row_no": self.row_no,
            "group_code_raw": self.group_code_raw,
            "group_code_key": self.group_code_key,
            "hotel_raw": self.hotel_raw,
            "room_items": [item.canonical_source() for item in self.room_items],
        }

    def sha256(self) -> str:
        return hashlib.sha256(canonical_json_bytes(self.canonical_source())).hexdigest()


@dataclass(frozen=True)
class ExcelDocument:
    source_sha256: str
    source_byte_size: int
    worksheets: tuple[str, ...]
    rows: tuple[ExcelRow, ...]

    @property
    def worksheet_count(self) -> int:
        return len(self.worksheets)

    @property
    def distinct_group_code_count(self) -> int:
        return len({row.group_code_key for row in self.rows})

    @property
    def extracted_item_count(self) -> int:
        return sum(len(row.room_items) for row in self.rows)

    @property
    def pending_item_count(self) -> int:
        return sum(row.pending_item_count for row in self.rows)

    @property
    def room_quantity(self) -> int:
        return sum(row.no_of_rooms for row in self.rows)

    @property
    def confirmed_room_quantity(self) -> int:
        return sum(row.confirmed_room_quantity for row in self.rows)


def canonical_json_bytes(value: object) -> bytes:
    return json.dumps(
        value,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")


def validate_excel_filename(value: object) -> str:
    if (
        not isinstance(value, str)
        or not 1 <= len(value) <= 255
        or value in {".", ".."}
        or PurePosixPath(value).name != value
        or "/" in value
        or "\\" in value
        or any(ord(char) < 32 or ord(char) == 127 for char in value)
        or not value.lower().endswith(".xlsx")
    ):
        raise BookingExcelError(
            "BOOKING_EXCEL_FILENAME_INVALID",
            "请选择 .xlsx 格式的 Excel 文件",
            400,
        )
    return value


def _normalized_header(value: object) -> str:
    if not isinstance(value, str):
        return ""
    normalized = unicodedata.normalize("NFKC", value).strip().upper()
    return re.sub(r"[\s._\-/\\:：()（）]+", "", normalized)


HEADER_LABELS = {
    "tour_code": (
        "Tour Code",
        "Group Code",
        "Gourp Code",
        "团号",
        "团队代码",
        "Tour Code / 主团号",
    ),
    "hotel": (
        "โรงแรม",
        "วางข้อมูลที่นี่ / 酒店明细",
        "Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细",
    ),
}

# Keep the approved source labels readable while applying one exact normalized
# comparison to every workbook header.  This is deliberately not substring or
# fuzzy matching: an unrecognized supplier change must fail safely.
HEADER_ALIASES = {
    key: frozenset(_normalized_header(label) for label in labels)
    for key, labels in HEADER_LABELS.items()
}

ROOM_BRACKET_PATTERN = re.compile(
    r"【(?P<label>[^】]{1," + str(MAX_ROOM_LABEL_LENGTH) + r"})】"
    r"(?P<spacing>\s*)(?P<quantity>[0-9]{1,4})?"
)
U_TWN_PATTERN = re.compile(r"^U[-‐‑‒–—]?TWN(?:[0-9]+(?:\.[0-9]+)?)?$", re.I)
U_DBL_PATTERN = re.compile(r"^U[-‐‑‒–—]?DBL(?:[0-9]+(?:\.[0-9]+)?)?$", re.I)
CANCELLATION_PATTERN = re.compile(
    r"取消|ยกเลิก|(?<![A-Za-z])CANCEL(?:L?ED)?(?![A-Za-z])",
    re.I,
)
IGNORED_NON_ROOM_TOKENS = (
    "SURCHARGE",
    "ABFCHILD",
    "ห้องไกด์",
)
UNBRACKETED_ROOM_NAMES = (
    "6+4",
    "FAMILY SUITE",
    "JUNIOR SUITE",
    "SUPERIOR GARDEN",
)


def _archive_preflight(payload: bytes) -> None:
    if not payload:
        raise BookingExcelError("BOOKING_EXCEL_EMPTY", "Excel 文件为空", 400)
    if len(payload) > MAX_UPLOAD_BYTES:
        raise BookingExcelError(
            "BOOKING_EXCEL_TOO_LARGE",
            "Excel 文件超过 25 MB",
            413,
        )
    stream = BytesIO(payload)
    if not zipfile.is_zipfile(stream):
        raise BookingExcelError(
            "BOOKING_EXCEL_INVALID",
            "文件不是有效的 .xlsx 工作簿",
        )
    stream.seek(0)
    try:
        with zipfile.ZipFile(stream) as archive:
            entries = archive.infolist()
            if not entries or len(entries) > MAX_ARCHIVE_ENTRIES:
                raise BookingExcelError(
                    "BOOKING_EXCEL_LIMIT_EXCEEDED",
                    "Excel 文件结构过大，无法安全处理",
                )
            names = set()
            expanded = 0
            for entry in entries:
                name = entry.filename.replace("\\", "/")
                path = PurePosixPath(name)
                if (
                    not name
                    or name.startswith("/")
                    or path.is_absolute()
                    or "." in path.parts
                    or ".." in path.parts
                    or entry.flag_bits & 0x1
                ):
                    raise BookingExcelError(
                        "BOOKING_EXCEL_INVALID",
                        "Excel 文件结构无效",
                    )
                names.add(name)
                expanded += entry.file_size
                if expanded > MAX_ARCHIVE_BYTES:
                    raise BookingExcelError(
                        "BOOKING_EXCEL_LIMIT_EXCEEDED",
                        "Excel 文件展开后过大，无法安全处理",
                    )
            if "[Content_Types].xml" not in names or "xl/workbook.xml" not in names:
                raise BookingExcelError(
                    "BOOKING_EXCEL_INVALID",
                    "文件不是有效的 .xlsx 工作簿",
                )
            if any(name.lower().endswith("vbaproject.bin") for name in names):
                raise BookingExcelError(
                    "BOOKING_EXCEL_MACRO_UNSUPPORTED",
                    "不支持包含宏的 Excel 文件",
                )
    except BookingExcelError:
        raise
    except (OSError, zipfile.BadZipFile, zipfile.LargeZipFile):
        raise BookingExcelError(
            "BOOKING_EXCEL_INVALID",
            "文件不是有效的 .xlsx 工作簿",
        ) from None


def _header_columns(cells: Iterable[object]) -> dict[str, int] | None:
    matches: dict[str, list[int]] = {key: [] for key in HEADER_ALIASES}
    for column_no, cell in enumerate(cells, start=1):
        value = getattr(cell, "value", cell)
        header = _normalized_header(value)
        if not header:
            continue
        for key, aliases in HEADER_ALIASES.items():
            if header in aliases:
                matches[key].append(column_no)
    if not all(matches[key] for key in matches):
        return None
    if any(len(matches[key]) != 1 for key in matches):
        raise BookingExcelError(
            "BOOKING_EXCEL_HEADERS_AMBIGUOUS",
            "Excel 表头重复，无法唯一确定 Tour Code/主团号或酒店明细列",
        )
    return {key: values[0] for key, values in matches.items()}


def normalize_tour_code(
    value: object,
    *,
    worksheet: str,
    row_no: int,
) -> str:
    if not isinstance(value, str):
        raise BookingExcelError(
            "BOOKING_EXCEL_ROW_INVALID",
            f"工作表“{worksheet}”第 {row_no} 行的 Tour Code 格式无效",
        )
    normalized = unicodedata.normalize("NFKC", value)
    normalized = re.sub(r"\s+", "", normalized)
    if (
        not normalized
        or len(normalized) > MAX_TOUR_CODE_LENGTH
        or any(ord(char) < 32 or ord(char) == 127 for char in normalized)
    ):
        raise BookingExcelError(
            "BOOKING_EXCEL_ROW_INVALID",
            f"工作表“{worksheet}”第 {row_no} 行的 Tour Code 格式无效",
        )
    return normalized


def _hotel_text(value: object, *, worksheet: str, row_no: int) -> str:
    if value in (None, ""):
        return ""
    if not isinstance(value, str):
        raise BookingExcelError(
            "BOOKING_EXCEL_ROW_INVALID",
            f"工作表“{worksheet}”第 {row_no} 行的โรงแรม格式无效",
        )
    normalized = unicodedata.normalize("NFKC", value).strip()
    if len(normalized) > MAX_HOTEL_TEXT_LENGTH or any(
        (ord(char) < 32 and char not in "\t\r\n") or ord(char) == 127
        for char in normalized
    ):
        raise BookingExcelError(
            "BOOKING_EXCEL_ROW_INVALID",
            f"工作表“{worksheet}”第 {row_no} 行的โรงแรม格式无效",
        )
    return normalized


def _cell_value(cell: object, *, worksheet: str, row_no: int) -> object:
    if getattr(cell, "data_type", None) == "f":
        raise BookingExcelError(
            "BOOKING_EXCEL_FORMULA_UNSUPPORTED",
            f"工作表“{worksheet}”第 {row_no} 行的必填字段不能使用公式",
        )
    return getattr(cell, "value", None)


def _compact_label(value: str) -> str:
    normalized = unicodedata.normalize("NFKC", value).strip().upper()
    return re.sub(r"\s+", "", normalized)


def _canonical_room_type(label: str) -> Optional[str]:
    compact = _compact_label(label)
    if U_TWN_PATTERN.fullmatch(compact):
        return "U-TWN"
    if U_DBL_PATTERN.fullmatch(compact):
        return "U-DBL"
    if compact in {"高级房TWN", "TWN"}:
        return "TWN"
    if compact in {"高级房DBL", "DBL"}:
        return "DBL"
    return None


def _ignored_non_room(label: str) -> bool:
    compact = _compact_label(label)
    return any(token in compact for token in IGNORED_NON_ROOM_TOKENS)


def _parenthesized_segments(value: str) -> list[str]:
    segments: list[str] = []
    stack: list[int] = []
    for index, char in enumerate(value):
        if char in "(（":
            stack.append(index)
        elif char in ")）" and stack:
            start = stack.pop()
            if not stack:
                segments.append(value[start + 1 : index])
    if stack:
        segments.append(value[stack[0] + 1 :])
    return segments


def _unbracketed_room_item(segment: str, item_no: int) -> Optional[ExtractedRoomItem]:
    upper = unicodedata.normalize("NFKC", segment).upper()
    label = next((name for name in UNBRACKETED_ROOM_NAMES if name in upper), None)
    if label is None:
        return None
    quantity_match = re.search(r"(?:^|\s)([0-9]{1,4})\s*$", segment)
    quantity = int(quantity_match.group(1)) if quantity_match else 1
    if not 1 <= quantity <= 9999:
        quantity = 1
    display_label = {
        "FAMILY SUITE": "Family Suite",
        "JUNIOR SUITE": "Junior Suite",
        "SUPERIOR GARDEN": "Superior Garden",
    }.get(label, label)
    return ExtractedRoomItem(
        item_no=item_no,
        room_type_raw=display_label,
        room_type_code=None,
        quantity=quantity,
        review_status=REVIEW_PENDING,
        source_fragment=segment.strip(),
    )


def extract_room_items(hotel_raw: str) -> tuple[ExtractedRoomItem, ...]:
    segments = _parenthesized_segments(hotel_raw)
    bracket_segments = [segment for segment in segments if "【" in segment]
    items: list[ExtractedRoomItem] = []
    saw_ignored_item = False

    for segment in bracket_segments:
        for match in ROOM_BRACKET_PATTERN.finditer(segment):
            label = match.group("label").strip()
            if _ignored_non_room(label):
                saw_ignored_item = True
                continue
            quantity = int(match.group("quantity") or "1")
            room_type_code = _canonical_room_type(label)
            items.append(
                ExtractedRoomItem(
                    item_no=len(items) + 1,
                    room_type_raw=label,
                    room_type_code=room_type_code,
                    quantity=quantity,
                    review_status=(
                        REVIEW_CONFIRMED
                        if room_type_code is not None
                        else REVIEW_PENDING
                    ),
                    source_fragment=match.group(0).strip(),
                )
            )
            if len(items) > MAX_EXTRACTED_ITEMS:
                raise BookingExcelError(
                    "BOOKING_EXCEL_LIMIT_EXCEEDED",
                    "Excel 可提取房型数量超出支持范围",
                )

    if items or saw_ignored_item:
        return tuple(items)

    for segment in segments:
        item = _unbracketed_room_item(segment, len(items) + 1)
        if item is not None:
            items.append(item)
    if items:
        return tuple(items)

    return (
        ExtractedRoomItem(
            item_no=1,
            room_type_raw=PENDING_ROOM_TYPE_LABEL,
            room_type_code=None,
            quantity=1,
            review_status=REVIEW_PENDING,
            source_fragment="",
        ),
    )


def _row_is_cancelled(cells: tuple[object, ...]) -> bool:
    text = " ".join(
        str(getattr(cell, "value", ""))
        for cell in cells
        if getattr(cell, "value", None) not in (None, "")
    )
    return CANCELLATION_PATTERN.search(text) is not None


def parse_excel_bytes(payload: bytes) -> ExcelDocument:
    _archive_preflight(payload)
    try:
        from openpyxl import load_workbook  # type: ignore[import-not-found]
    except ImportError:
        raise BookingExcelError(
            "BOOKING_EXCEL_SERVICE_UNAVAILABLE",
            "Excel 校验服务暂不可用",
            503,
        ) from None

    try:
        workbook = load_workbook(
            BytesIO(payload),
            read_only=True,
            data_only=False,
            keep_links=False,
        )
    except Exception:
        raise BookingExcelError(
            "BOOKING_EXCEL_INVALID",
            "文件不是有效的 .xlsx 工作簿",
        ) from None

    latest_by_tour: dict[str, tuple[int, Optional[ExcelRow]]] = {}
    data_sheets: list[str] = []
    sequence = 0
    source_rows_seen = 0
    extracted_items_seen = 0
    try:
        if not workbook.worksheets or len(workbook.worksheets) > MAX_WORKSHEETS:
            raise BookingExcelError(
                "BOOKING_EXCEL_LIMIT_EXCEEDED",
                "Excel 工作表数量超出支持范围",
            )
        for worksheet in workbook.worksheets:
            title = str(worksheet.title or "").strip()
            if not title or len(title) > 31:
                raise BookingExcelError(
                    "BOOKING_EXCEL_INVALID",
                    "Excel 工作表名称无效",
                )
            if (
                isinstance(worksheet.max_column, int)
                and worksheet.max_column > MAX_WORKSHEET_COLUMNS
            ) or (
                isinstance(worksheet.max_row, int)
                and worksheet.max_row > MAX_WORKSHEET_ROWS
            ):
                raise BookingExcelError(
                    "BOOKING_EXCEL_LIMIT_EXCEEDED",
                    f"工作表“{title}”的范围超出支持限制",
                )

            header_row_no = 0
            columns: dict[str, int] | None = None
            for scan_no, cells in enumerate(
                worksheet.iter_rows(
                    min_row=1,
                    max_row=MAX_HEADER_ROWS,
                    max_col=MAX_WORKSHEET_COLUMNS,
                ),
                start=1,
            ):
                columns = _header_columns(cells)
                if columns is not None:
                    header_row_no = scan_no
                    break
            if columns is None:
                continue

            data_sheets.append(title)
            row_width = min(
                MAX_WORKSHEET_COLUMNS,
                max(
                    max(columns.values()),
                    int(worksheet.max_column or max(columns.values())),
                ),
            )
            for row_no, cells in enumerate(
                worksheet.iter_rows(
                    min_row=header_row_no + 1,
                    max_row=MAX_WORKSHEET_ROWS,
                    max_col=row_width,
                ),
                start=header_row_no + 1,
            ):
                cells = tuple(cells)
                tour_value = _cell_value(
                    cells[columns["tour_code"] - 1],
                    worksheet=title,
                    row_no=row_no,
                )
                hotel_value = _cell_value(
                    cells[columns["hotel"] - 1],
                    worksheet=title,
                    row_no=row_no,
                )
                if tour_value in (None, ""):
                    continue
                tour_code = normalize_tour_code(
                    tour_value,
                    worksheet=title,
                    row_no=row_no,
                )
                group_code_key = tour_code.upper()
                sequence += 1
                source_rows_seen += 1
                if source_rows_seen > MAX_SOURCE_ROWS:
                    raise BookingExcelError(
                        "BOOKING_EXCEL_LIMIT_EXCEEDED",
                        "Excel 数据行超过 50000 行",
                    )
                if _row_is_cancelled(cells):
                    latest_by_tour[group_code_key] = (sequence, None)
                    continue

                hotel_raw = _hotel_text(
                    hotel_value,
                    worksheet=title,
                    row_no=row_no,
                )
                room_items = extract_room_items(hotel_raw)
                extracted_items_seen += len(room_items)
                if extracted_items_seen > MAX_EXTRACTED_ITEMS:
                    raise BookingExcelError(
                        "BOOKING_EXCEL_LIMIT_EXCEEDED",
                        "Excel 可提取房型数量超出支持范围",
                    )
                latest_by_tour[group_code_key] = (
                    sequence,
                    ExcelRow(
                        worksheet=title,
                        row_no=row_no,
                        group_code_raw=tour_code,
                        hotel_raw=hotel_raw or PENDING_ROOM_TYPE_LABEL,
                        room_items=room_items,
                    )
                    if room_items
                    else None,
                )
    finally:
        workbook.close()

    rows = tuple(
        row
        for _, row in sorted(latest_by_tour.values(), key=lambda value: value[0])
        if row is not None
    )
    if not data_sheets:
        raise BookingExcelError(
            "BOOKING_EXCEL_HEADERS_MISSING",
            "Excel 中未找到同时包含 Tour Code/主团号和酒店明细的工作表",
        )
    if not rows:
        raise BookingExcelError(
            "BOOKING_EXCEL_NO_DATA",
            "Excel 中没有可提取的 Booking 数据",
        )
    return ExcelDocument(
        source_sha256=hashlib.sha256(payload).hexdigest(),
        source_byte_size=len(payload),
        worksheets=tuple(data_sheets),
        rows=rows,
    )


def rule_set_sha256() -> str:
    rules = {
        "headers": {key: sorted(value) for key, value in HEADER_ALIASES.items()},
        "u_twn": U_TWN_PATTERN.pattern,
        "u_dbl": U_DBL_PATTERN.pattern,
        "ignored_non_room": list(IGNORED_NON_ROOM_TOKENS),
        "unbracketed_room_names": list(UNBRACKETED_ROOM_NAMES),
        "cancellation": CANCELLATION_PATTERN.pattern,
    }
    digest = hashlib.sha256()
    digest.update(PROCESSOR_NAME.encode("ascii"))
    digest.update(b"\0")
    digest.update(PROCESSOR_VERSION.encode("ascii"))
    digest.update(b"\0")
    digest.update(canonical_json_bytes(rules))
    return digest.hexdigest()


def build_excel_parse_result(
    row: ExcelRow,
    room_items: Optional[Iterable[ExtractedRoomItem]] = None,
) -> Mapping[str, object]:
    selected_items = tuple(room_items) if room_items is not None else row.room_items
    pending = [item for item in selected_items if item.needs_review]
    status = "needs_review" if pending else "accepted"
    row_sha256 = row.sha256()
    return {
        "result_schema_version": RESULT_SCHEMA_VERSION,
        "status": status,
        "source_row": {
            "worksheet": row.worksheet,
            "row_no": row.row_no,
            "sha256": row_sha256,
        },
        "processor_version": PROCESSOR_VERSION,
        "rule_set_sha256": rule_set_sha256(),
        "group_code_raw": row.group_code_raw,
        "group_code_key": row.group_code_key,
        "type_of_room_raw": row.hotel_raw,
        "no_of_rooms": sum(item.quantity for item in selected_items),
        "room_items": [
            {
                "item_no": item.item_no,
                "room_type_raw": item.room_type_raw,
                "room_type_code": item.room_type_code,
                "quantity": item.quantity,
                "unit_price": None,
                "currency_code": None,
                "price_token_raw": None,
                "source_fragment": item.source_fragment,
            }
            for item in selected_items
        ],
        "warnings": [
            {
                "code": "BOOKING_ROOM_TYPE_REVIEW_REQUIRED",
                "message": "房型需人工确认",
            }
            for _ in pending
        ],
        "errors": [],
    }
