#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Standalone Lark Docs word and character counter for XML or Markdown input."""

from __future__ import annotations

import argparse
import json
import re
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, Protocol
from xml.etree import ElementTree as ET

# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------

@dataclass
class TextRun:
    text: str
    attrs: dict[str, Any] = field(default_factory=dict)


@dataclass
class Block:
    type: str
    attrs: dict[str, Any] = field(default_factory=dict)
    children: list["Block"] = field(default_factory=list)
    text_runs: list[TextRun] = field(default_factory=list)
    raw: Any = None


@dataclass
class Segment:
    text: str
    block_type: str
    block_id: str | None = None
    kind: str = "text"
    boundary_before: bool = True
    boundary_after: bool = True

    def to_dict(self) -> dict[str, Any]:
        return {
            "text": self.text,
            "block_type": self.block_type,
            "block_id": self.block_id,
            "kind": self.kind,
            "boundary_before": self.boundary_before,
            "boundary_after": self.boundary_after,
        }


@dataclass(frozen=True)
class UnknownBlock:
    type: str
    block_id: str | None = None
    action: str = "recurse_children"

    def to_dict(self) -> dict[str, str | None]:
        return {
            "type": self.type,
            "block_id": self.block_id,
            "action": self.action,
        }

# ---------------------------------------------------------------------------
# Counting rules
# ---------------------------------------------------------------------------

CHINESE_PUNCTUATION = set("，。！？；：、（）《》〈〉“”‘’【】「」『』〔〕…—～·￥")
ENGLISH_PUNCTUATION = set(
    r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
)


LexemeKind = Literal["english", "number"]
URL_TOKEN_RE = re.compile(r"https?://[!-~]+")
ASCII_COMPOUND_TOKEN_RE = re.compile(
    r"[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+"
)


@dataclass
class Stats:
    word_count: int = 0
    char_count: int = 0
    han_chars: int = 0
    english_words: int = 0
    number_words: int = 0
    chinese_punctuations: int = 0
    english_letters: int = 0
    digits: int = 0
    english_punctuations: int = 0
    symbol_words: int = 0
    symbol_chars: int = 0

    def to_dict(self) -> dict[str, object]:
        return {
            "word_count": self.word_count,
            "char_count": self.char_count,
            "breakdown": {
                "han_chars": self.han_chars,
                "english_words": self.english_words,
                "number_words": self.number_words,
                "chinese_punctuations": self.chinese_punctuations,
                "english_letters": self.english_letters,
                "digits": self.digits,
                "english_punctuations": self.english_punctuations,
                "symbol_words": self.symbol_words,
                "symbol_chars": self.symbol_chars,
            },
        }


def is_han(ch: str) -> bool:
    code = ord(ch)
    return (
        0x3400 <= code <= 0x4DBF
        or 0x4E00 <= code <= 0x9FFF
        or 0xF900 <= code <= 0xFAFF
        or 0x20000 <= code <= 0x2A6DF
        or 0x2A700 <= code <= 0x2B73F
        or 0x2B740 <= code <= 0x2B81F
        or 0x2B820 <= code <= 0x2CEAF
        or 0x30000 <= code <= 0x3134F
    )


def is_ascii_letter(ch: str) -> bool:
    return ("a" <= ch <= "z") or ("A" <= ch <= "Z")


def is_digit(ch: str) -> bool:
    return "0" <= ch <= "9"


def is_chinese_punctuation(ch: str) -> bool:
    if ch in CHINESE_PUNCTUATION:
        return True
    return unicodedata.category(ch).startswith("P") and unicodedata.east_asian_width(ch) in {
        "W",
        "F",
    }


def is_english_punctuation(ch: str) -> bool:
    return ch in ENGLISH_PUNCTUATION


def is_unicode_symbol(ch: str) -> bool:
    return unicodedata.category(ch).startswith("S")


def utf16_units(ch: str) -> int:
    return len(ch.encode("utf-16-le")) // 2


class Counter:
    def __init__(self) -> None:
        self.stats = Stats()
        self._lexeme_kind: LexemeKind | None = None
        self._lexeme_has_digit = False
        self._symbol_run_length = 0
        self._at_boundary = True

    def count_segments(self, segments: list[Segment]) -> Stats:
        for segment in segments:
            if segment.boundary_before:
                self._end_unit()
                self._at_boundary = True
            if segment.kind == "marker":
                self.write_marker(segment.text)
            elif segment.kind == "code":
                self.write_code(segment.text)
            else:
                self.write(segment.text)
            if segment.boundary_after:
                self._end_unit()
                self._at_boundary = True
        self._end_unit()
        return self.stats

    def write(self, text: str) -> None:
        i = 0
        while i < len(text):
            consumed = self._write_ascii_compound_token(text, i)
            if consumed:
                i += consumed
                continue
            if self._write_visible_ascii_separator(text, i):
                i += 1
                continue
            self._write_char(text[i])
            i += 1

    def write_marker(self, text: str) -> None:
        for ch in text:
            if ch.isspace():
                continue
            self._end_unit()
            self.stats.word_count += 1
            self.stats.char_count += 1
            self._at_boundary = False

    def write_code(self, text: str) -> None:
        for ch in text:
            self._write_code_char(ch)

    def _write_code_char(self, ch: str) -> None:
        if ch.isspace():
            self._end_unit()
            self._at_boundary = True
            return

        if is_han(ch):
            self._end_lexeme()
            self._end_symbol_run(count_word=False)
            self.stats.han_chars += 1
            self.stats.word_count += 1
            self.stats.char_count += 1
            self._at_boundary = False
            return

        if is_ascii_letter(ch):
            self._end_symbol_run(count_word=False)
            self.stats.english_letters += 1
            self.stats.char_count += 1
            if self._lexeme_kind is None:
                self._lexeme_kind = "english"
            elif self._lexeme_kind == "number":
                self._lexeme_kind = "english"
            self._at_boundary = False
            return

        if is_digit(ch):
            self._end_symbol_run(count_word=False)
            self.stats.digits += 1
            self.stats.char_count += 1
            self._at_boundary = False
            return

        if is_chinese_punctuation(ch):
            self._end_lexeme()
            self._end_symbol_run(count_word=False)
            self.stats.chinese_punctuations += 1
            self.stats.word_count += 1
            self.stats.char_count += 1
            self._at_boundary = False
            return

        if is_english_punctuation(ch):
            keeps_lexeme = self._lexeme_kind == "english" and ch in {"'", "-"}
            if not keeps_lexeme:
                had_lexeme = self._lexeme_kind is not None
                self._end_lexeme()
                if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary):
                    self._symbol_run_length += 1
            self.stats.english_punctuations += 1
            self.stats.char_count += 1
            if keeps_lexeme:
                self._at_boundary = False
            return

        if is_unicode_symbol(ch):
            self._write_symbol_char(ch)
            return

        self._end_lexeme()
        self._end_symbol_run(count_word=False)
        self._at_boundary = False

    def _write_char(self, ch: str) -> None:
        if ch.isspace():
            self._end_unit()
            self._at_boundary = True
            return

        if is_han(ch):
            self._end_lexeme()
            self._end_symbol_run(count_word=False)
            self.stats.han_chars += 1
            self.stats.word_count += 1
            self.stats.char_count += 1
            self._at_boundary = False
            return

        if is_ascii_letter(ch):
            self._end_symbol_run(count_word=False)
            self.stats.english_letters += 1
            self.stats.char_count += 1
            if self._lexeme_kind is None:
                self._lexeme_kind = "english"
            elif self._lexeme_kind == "number":
                self._lexeme_kind = "english"
            self._at_boundary = False
            return

        if is_digit(ch):
            self._end_symbol_run(count_word=False)
            self.stats.digits += 1
            self.stats.char_count += 1
            self._lexeme_has_digit = True
            if self._lexeme_kind is None:
                self._lexeme_kind = "number"
            self._at_boundary = False
            return

        if is_chinese_punctuation(ch):
            self._end_lexeme()
            self._end_symbol_run(count_word=False)
            self.stats.chinese_punctuations += 1
            self.stats.word_count += 1
            self.stats.char_count += 1
            self._at_boundary = False
            return

        if is_english_punctuation(ch):
            # Apostrophes/hyphens can connect English runs. Dot/comma/hyphen
            # can format numeric runs such as 3.14, 1,000, 2026-06-30, or
            # 7-9. Alphanumeric versions like v1.2.3 should remain one semantic
            # run too. These punctuations still count as characters.
            keeps_lexeme = (
                self._lexeme_kind == "english"
                and (ch in {"'", "-"} or (self._lexeme_has_digit and ch == "."))
            ) or (
                self._lexeme_kind == "number"
                and ch in {".", ",", "-"}
            )
            if not keeps_lexeme:
                had_lexeme = self._lexeme_kind is not None
                self._end_lexeme()
                if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary):
                    self._symbol_run_length += 1
            self.stats.english_punctuations += 1
            self.stats.char_count += 1
            if keeps_lexeme:
                self._at_boundary = False
            return

        if is_unicode_symbol(ch):
            self._write_symbol_char(ch)
            return

        self._end_lexeme()
        self._end_symbol_run(count_word=False)
        self._at_boundary = False

    def _write_visible_ascii_separator(self, text: str, index: int) -> bool:
        ch = text[index]
        if ch != "/" or index == 0 or index + 1 >= len(text):
            return False
        if not is_han(text[index - 1]) or not is_han(text[index + 1]):
            return False

        self._end_unit()
        self.stats.english_punctuations += 1
        self.stats.symbol_words += 1
        self.stats.word_count += 1
        self.stats.char_count += 1
        self._at_boundary = False
        return True

    def _write_ascii_compound_token(self, text: str, start: int) -> int:
        token = self._match_ascii_compound_token(text, start)
        if not token:
            return 0

        self._end_unit()
        self.stats.english_words += 1
        self.stats.word_count += 1
        for ch in token:
            if is_ascii_letter(ch):
                self.stats.english_letters += 1
                self.stats.char_count += 1
            elif is_digit(ch):
                self.stats.digits += 1
                self.stats.char_count += 1
            elif is_english_punctuation(ch):
                self.stats.english_punctuations += 1
                self.stats.char_count += 1
            elif is_unicode_symbol(ch):
                units = utf16_units(ch)
                self.stats.symbol_chars += units
                self.stats.char_count += units
            elif is_chinese_punctuation(ch):
                self.stats.chinese_punctuations += 1
                self.stats.char_count += 1
            elif is_han(ch):
                self.stats.han_chars += 1
                self.stats.char_count += 1
        self._at_boundary = False
        return len(token)

    def _match_ascii_compound_token(self, text: str, start: int) -> str | None:
        match = URL_TOKEN_RE.match(text, start)
        if match:
            return match.group(0)

        match = ASCII_COMPOUND_TOKEN_RE.match(text, start)
        if not match:
            return None
        token = match.group(0)
        if any(is_ascii_letter(ch) for ch in token):
            return token
        return None

    def _write_symbol_char(self, ch: str) -> None:
        self._end_lexeme()
        self._end_symbol_run(count_word=False)
        units = utf16_units(ch)
        self.stats.symbol_words += 1
        self.stats.symbol_chars += units
        self.stats.word_count += 1
        self.stats.char_count += units
        self._at_boundary = False

    def _end_unit(self) -> None:
        self._end_lexeme()
        self._end_symbol_run(count_word=True)

    def _end_lexeme(self) -> None:
        if self._lexeme_kind == "english":
            self.stats.english_words += 1
            self.stats.word_count += 1
        elif self._lexeme_kind == "number":
            self.stats.number_words += 1
            self.stats.word_count += 1
        self._lexeme_kind = None
        self._lexeme_has_digit = False

    def _end_symbol_run(self, *, count_word: bool) -> None:
        if self._symbol_run_length >= 1 and count_word:
            self.stats.symbol_words += 1
            self.stats.word_count += 1
        if self._symbol_run_length:
            self._at_boundary = False
        self._symbol_run_length = 0

# ---------------------------------------------------------------------------
# Markdown parser
# ---------------------------------------------------------------------------

HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
LIST_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$")
QUOTE_RE = re.compile(r"^\s*>\s?(.*)$")
TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")


def parse_markdown(source: str) -> list[Block]:
    lines = source.splitlines()
    blocks: list[Block] = []
    paragraph: list[str] = []
    i = 0

    def flush_paragraph() -> None:
        if paragraph:
            blocks.append(Block(type="paragraph", text_runs=[TextRun(clean_inline(" ".join(paragraph)))]))
            paragraph.clear()

    while i < len(lines):
        line = lines[i]
        stripped = line.strip()
        if not stripped:
            flush_paragraph()
            i += 1
            continue

        if stripped.startswith("```") or stripped.startswith("~~~"):
            flush_paragraph()
            fence = stripped[:3]
            code_lines: list[str] = []
            i += 1
            while i < len(lines) and not lines[i].strip().startswith(fence):
                code_lines.append(lines[i])
                i += 1
            if i < len(lines):
                i += 1
            blocks.append(Block(type="code", text_runs=[TextRun("\n".join(code_lines))]))
            continue

        heading = HEADING_RE.match(line)
        if heading:
            flush_paragraph()
            blocks.append(Block(type="heading", text_runs=[TextRun(clean_inline(heading.group(2)))]))
            i += 1
            continue

        if _looks_like_table(lines, i):
            flush_paragraph()
            table, consumed = _parse_table(lines, i)
            blocks.append(table)
            i += consumed
            continue

        item = LIST_RE.match(line)
        if item:
            flush_paragraph()
            items: list[Block] = []
            while i < len(lines):
                match = LIST_RE.match(lines[i])
                if not match:
                    break
                items.append(Block(type="list_item", text_runs=[TextRun(clean_inline(match.group(1)))]))
                i += 1
            blocks.append(Block(type="list", children=items))
            continue

        quote = QUOTE_RE.match(line)
        if quote:
            flush_paragraph()
            quote_lines: list[str] = []
            while i < len(lines):
                match = QUOTE_RE.match(lines[i])
                if not match:
                    break
                quote_lines.append(match.group(1))
                i += 1
            blocks.append(Block(type="quote", text_runs=[TextRun(clean_inline(" ".join(quote_lines)))]))
            continue

        paragraph.append(stripped)
        i += 1

    flush_paragraph()
    return blocks


def clean_inline(text: str) -> str:
    text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text)
    text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
    text = re.sub(r"([*_`~]{1,3})(.*?)\1", r"\2", text)
    text = text.replace("\\", "")
    return text


def _looks_like_table(lines: list[str], i: int) -> bool:
    return i + 1 < len(lines) and "|" in lines[i] and TABLE_SEP_RE.match(lines[i + 1]) is not None


def _parse_table(lines: list[str], i: int) -> tuple[Block, int]:
    rows: list[Block] = []
    consumed = 0
    while i + consumed < len(lines):
        line = lines[i + consumed]
        stripped = line.strip()
        if not stripped or "|" not in stripped:
            break
        if consumed == 1 and TABLE_SEP_RE.match(stripped):
            consumed += 1
            continue
        cells = [cell.strip() for cell in stripped.strip("|").split("|")]
        row = Block(
            type="tr",
            children=[
                Block(type="table_cell", text_runs=[TextRun(clean_inline(cell))])
                for cell in cells
                if cell
            ],
        )
        rows.append(row)
        consumed += 1
    return Block(type="table", children=rows), consumed

# ---------------------------------------------------------------------------
# XML parser
# ---------------------------------------------------------------------------

INLINE_TAGS = {
    "b",
    "strong",
    "i",
    "em",
    "u",
    "s",
    "del",
    "span",
    "text",
    "plain_text",
    "code",
    "a",
    "link",
    "mention",
    "mention-doc",
    "mention-user",
}

TYPE_ALIASES = {
    "doc": "document",
    "document": "document",
    "fragment": "fragment",
    "p": "paragraph",
    "paragraph": "paragraph",
    "heading": "heading",
    "h1": "heading",
    "h2": "heading",
    "h3": "heading",
    "h4": "heading",
    "h5": "heading",
    "h6": "heading",
    "h7": "heading",
    "h8": "heading",
    "h9": "heading",
    "ul": "list",
    "ol": "list",
    "li": "list_item",
    "task": "task",
    "todo": "list_item",
    "blockquote": "quote",
    "quote": "quote",
    "br": "br",
    "hr": "hr",
    "title": "title",
    "checkbox": "checkbox",
    "grid": "grid",
    "column": "column",
    "table": "table",
    "colgroup": "colgroup",
    "col": "col",
    "tr": "tr",
    "td": "table_cell",
    "th": "table_cell",
    "pre": "code",
    "code_block": "code",
    "callout": "callout",
    "figure": "figure",
    "toggle": "toggle",
    "img": "image",
    "source": "source",
    "file": "file",
    "media": "media",
    "latex": "latex",
    "cite": "cite",
    "bookmark": "bookmark",
    "button": "button",
    "whiteboard": "whiteboard",
    "mermaid": "mermaid",
    "plantuml": "plantuml",
    "poll": "poll",
    "isv": "isv",
    "mindnote": "mindnote",
    "diagram": "diagram",
    "sheet": "sheet",
    "bitable": "bitable",
    "base-ref": "base_ref",
    "base_ref": "base_ref",
    "base-refer": "base_ref",
    "base_refer": "base_ref",
    "synced-reference": "synced_reference",
    "synced_reference": "synced_reference",
    "synced-source": "synced_source",
    "synced_source": "synced_source",
    "okr": "okr",
    "chat-card": "chat_card",
    "chat_card": "chat_card",
    "sub_page_list": "sub-page-list",
    "sub-page-list": "sub-page-list",
}

SUBTYPE_ATTR_TAGS = {
    "a",
    "button",
    "cite",
    "img",
    "sheet",
    "source",
    "whiteboard",
    "base-ref",
    "base_ref",
    "base-refer",
    "base_refer",
    "synced-reference",
    "synced_reference",
    "synced-source",
    "synced_source",
    "okr",
    "chat-card",
    "chat_card",
    "sub_page_list",
    "sub-page-list",
}

MAX_XML_INPUT_CHARS = 20_000_000
FORBIDDEN_XML_DECL_RE = re.compile(r"<!\s*(?:DOCTYPE|ENTITY)\b", re.IGNORECASE)


class UserInputError(ValueError):
    pass


def local_name(tag: str) -> str:
    if "}" in tag:
        return tag.rsplit("}", 1)[1]
    return tag


def block_type_for(elem: ET.Element) -> str:
    tag = local_name(elem.tag)
    explicit = elem.attrib.get("block_type")
    if explicit is None and tag not in SUBTYPE_ATTR_TAGS:
        explicit = elem.attrib.get("type")
    if explicit:
        return TYPE_ALIASES.get(explicit, explicit)
    return TYPE_ALIASES.get(tag, tag)


def ensure_safe_xml_source(source: str) -> None:
    if len(source) > MAX_XML_INPUT_CHARS:
        raise UserInputError(
            f"XML input is too large ({len(source)} chars, limit {MAX_XML_INPUT_CHARS})"
        )
    if FORBIDDEN_XML_DECL_RE.search(source):
        raise UserInputError("XML input must not contain DOCTYPE or ENTITY declarations")


def parse_xml(source: str) -> list[Block]:
    source = source.strip()
    if not source:
        return []
    ensure_safe_xml_source(source)
    try:
        root = ET.fromstring(source)
    except ET.ParseError:
        # docs +fetch raw output can occasionally include adjacent top-level
        # blocks. Wrap them so standard ElementTree can parse the stream.
        root = ET.fromstring(f"<fragment>{source}</fragment>")
    return [_parse_block(root)]


def _parse_block(elem: ET.Element) -> Block:
    block = Block(type=block_type_for(elem), attrs=dict(elem.attrib), raw=elem)
    _collect_content(elem, block)
    if not block.text_runs and not block.children:
        if block.type == "image":
            display = elem.attrib.get("caption")
        else:
            display = (
                elem.attrib.get("text")
                or elem.attrib.get("name")
                or elem.attrib.get("title")
                or elem.attrib.get("alt")
                or elem.attrib.get("caption")
            )
        if display:
            block.text_runs.append(TextRun(display, dict(elem.attrib)))
    return block


def _collect_content(elem: ET.Element, block: Block) -> None:
    if elem.text:
        block.text_runs.append(TextRun(elem.text))

    for child in list(elem):
        tag = local_name(child.tag)
        if tag == "br":
            block.text_runs.append(TextRun("\n"))
        elif tag in INLINE_TAGS:
            _collect_inline(child, block)
        else:
            block.children.append(_parse_block(child))
        if child.tail:
            block.text_runs.append(TextRun(child.tail))


def _collect_inline(elem: ET.Element, block: Block) -> None:
    if local_name(elem.tag) == "br":
        block.text_runs.append(TextRun("\n", dict(elem.attrib)))
        return

    display = (
        elem.attrib.get("text")
        or elem.attrib.get("name")
        or elem.attrib.get("title")
        or elem.attrib.get("alt")
    )
    if display:
        block.text_runs.append(TextRun(display, dict(elem.attrib)))
        return

    if elem.text:
        block.text_runs.append(TextRun(elem.text, dict(elem.attrib)))
    for child in list(elem):
        _collect_inline(child, block)
        if child.tail:
            block.text_runs.append(TextRun(child.tail))

# ---------------------------------------------------------------------------
# Block extraction registry
# ---------------------------------------------------------------------------

@dataclass
class ExtractContext:
    unknown_blocks: list[UnknownBlock] = field(default_factory=list)
    resource_texts: dict[str, str] = field(default_factory=dict)


class Handler(Protocol):
    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        raise NotImplementedError


def block_id(block: Block) -> str | None:
    for key in ("id", "block_id", "block-id", "token"):
        value = block.attrs.get(key)
        if isinstance(value, str) and value:
            return value
    return None


def runs_text(block: Block) -> str:
    return "".join(run.text for run in block.text_runs)


def raw_tag(block: Block) -> str:
    tag = getattr(getattr(block, "raw", None), "tag", "") or ""
    if "}" in tag:
        return tag.rsplit("}", 1)[1]
    return tag


class TextBlockHandler:
    def __init__(self, kind: str = "text") -> None:
        self.kind = kind

    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        segments: list[Segment] = []
        text = runs_text(block)
        if text.strip():
            segments.append(
                Segment(
                    text=text,
                    block_type=block.type,
                    block_id=block_id(block),
                    kind=self.kind,
                )
            )
        for child in block.children:
            segments.extend(registry.extract(child, ctx))
        return segments


class ContainerHandler:
    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        segments: list[Segment] = []
        text = runs_text(block)
        if text.strip():
            segments.append(
                Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text")
            )
        for child in block.children:
            segments.extend(registry.extract(child, ctx))
        return segments


class ListHandler:
    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        tag = raw_tag(block)
        if tag not in {"ol", "ul"}:
            return ContainerHandler().extract(block, registry, ctx)

        segments: list[Segment] = []
        text = runs_text(block)
        if text.strip():
            segments.append(
                Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text")
            )

        next_seq = 1
        for child in block.children:
            if child.type == "list_item":
                if tag == "ol":
                    seq = child.attrs.get("seq")
                    if isinstance(seq, str) and seq.isdigit():
                        marker = seq
                        next_seq = int(seq) + 1
                    else:
                        marker = str(next_seq)
                        next_seq += 1
                    segments.append(
                        Segment(
                            text=f"{marker}.",
                            block_type="list_marker",
                            block_id=block_id(child),
                            kind="text",
                        )
                    )
                else:
                    segments.append(
                        Segment(
                            text="•",
                            block_type="list_marker",
                            block_id=block_id(child),
                            kind="marker",
                        )
                    )
            segments.extend(registry.extract(child, ctx))
        return segments


class CheckboxHandler(TextBlockHandler):
    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        return [
            Segment(
                text="☑" if block.attrs.get("done") == "true" else "☐",
                block_type="checkbox_marker",
                block_id=block_id(block),
                kind="marker",
            ),
            *super().extract(block, registry, ctx),
        ]


class UnknownHandler:
    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block)))
        return ContainerHandler().extract(block, registry, ctx)


class IgnoreHandler:
    def __init__(self, action: str = "ignored") -> None:
        self.action = action

    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=self.action))
        return []


class TaskHandler:
    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        task_id = block.attrs.get("task-id") or block.attrs.get("task_id")
        if isinstance(task_id, str) and task_id:
            text = ctx.resource_texts.get(f"task:{task_id}")
            if text and text.strip():
                marker = "☑" if block.attrs.get("status") in {"done", "completed", "complete"} else "☐"
                return [
                    Segment(text=marker, block_type="task_marker", block_id=block_id(block), kind="marker"),
                    Segment(text=text, block_type="task", block_id=block_id(block), kind="resource_title"),
                ]

        ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="ignored_resource"))
        return []


class WhiteboardHandler:
    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        board_type = block.attrs.get("type")
        is_empty_resource_shell = not board_type and not block.children and not runs_text(block).strip()
        action = (
            "ignored_resource"
            if board_type in {"blank", "mermaid", "plantuml", "svg"} or is_empty_resource_shell
            else "unsupported_resource"
        )
        ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=action))
        return []


class SyncedSourceHandler:
    def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]:
        if block.children:
            segments: list[Segment] = []
            for child in block.children:
                segments.extend(registry.extract(child, ctx))
            return segments

        ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="unsupported_resource"))
        return []


class Registry:
    def __init__(self) -> None:
        self._handlers: dict[str, Handler] = {}
        self._unknown = UnknownHandler()

    def register(self, *types: str, handler: Handler) -> None:
        for typ in types:
            self._handlers[typ] = handler

    def extract(self, block: Block, ctx: ExtractContext) -> list[Segment]:
        handler = self._handlers.get(block.type, self._unknown)
        return handler.extract(block, self, ctx)


def default_registry() -> Registry:
    registry = Registry()
    registry.register("document", "fragment", "root", handler=ContainerHandler())
    registry.register("title", handler=TextBlockHandler("title"))
    registry.register("paragraph", "p", handler=TextBlockHandler("text"))
    registry.register(
        "heading",
        "h",
        "h1",
        "h2",
        "h3",
        "h4",
        "h5",
        "h6",
        "h7",
        "h8",
        "h9",
        handler=TextBlockHandler("heading"),
    )
    registry.register("list", "ul", "ol", handler=ListHandler())
    registry.register("list_item", "li", "todo", handler=TextBlockHandler("list_item"))
    registry.register("checkbox", handler=CheckboxHandler("list_item"))
    registry.register(
        "quote",
        "blockquote",
        "callout",
        "toggle",
        "grid",
        "column",
        "figure",
        handler=ContainerHandler(),
    )
    registry.register("table", "thead", "tbody", "tr", handler=ContainerHandler())
    registry.register("table_cell", "td", "th", handler=TextBlockHandler("table_cell"))
    registry.register("code", "code_block", "pre", handler=TextBlockHandler("code"))
    registry.register("link", "a", "mention", "mention-doc", "mention-user", "time", handler=TextBlockHandler("inline"))
    registry.register("image", "img", handler=TextBlockHandler("caption"))
    registry.register("colgroup", "col", "br", "hr", handler=IgnoreHandler("ignored_structure"))
    registry.register("button", "cite", "latex", "bookmark", handler=IgnoreHandler("ignored_inline"))
    registry.register("task", handler=TaskHandler())
    registry.register("whiteboard", handler=WhiteboardHandler())
    registry.register("synced_source", handler=SyncedSourceHandler())
    registry.register(
        "mermaid",
        "sheet",
        "source",
        "file",
        "media",
        "chat_card",
        "base_ref",
        "bitable",
        "synced_reference",
        "poll",
        "isv",
        "mindnote",
        "diagram",
        "sub-page-list",
        handler=IgnoreHandler("ignored_resource"),
    )
    registry.register(
        "okr",
        "plantuml",
        handler=IgnoreHandler("unsupported_resource"),
    )
    return registry

# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

VERSION = "0.1-alpha"


def build_diagnostics(items: list) -> dict[str, object]:
    actions: dict[str, int] = {}
    types: dict[str, int] = {}
    unsupported_types: dict[str, int] = {}
    unknown_types: dict[str, int] = {}
    for item in items:
        actions[item.action] = actions.get(item.action, 0) + 1
        types[item.type] = types.get(item.type, 0) + 1
        if item.action == "unsupported_resource":
            unsupported_types[item.type] = unsupported_types.get(item.type, 0) + 1
        if item.action == "recurse_children":
            unknown_types[item.type] = unknown_types.get(item.type, 0) + 1
    return {
        "actions": actions,
        "types": types,
        "unsupported_types": unsupported_types,
        "unknown_types": unknown_types,
        "has_unsupported": bool(unsupported_types),
        "has_unknown": bool(unknown_types),
    }


def read_input(path: str) -> str:
    if path == "-":
        return sys.stdin.read()
    return Path(path).read_text(encoding="utf-8")


def read_resource_texts(path: str | None) -> dict[str, str]:
    if not path:
        return {}
    payload = json.loads(Path(path).read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        raise ValueError("--resource-texts must be a JSON object")
    return {str(key): str(value) for key, value in payload.items()}


def extract_lark_json_content(source: str) -> str:
    try:
        envelope = json.loads(source)
    except json.JSONDecodeError as exc:
        raise UserInputError(f"could not parse lark-cli JSON envelope: {exc}") from exc

    if not isinstance(envelope, dict):
        raise UserInputError("lark-cli JSON envelope must be an object")
    data = envelope.get("data")
    if not isinstance(data, dict):
        raise UserInputError("lark-cli JSON envelope is missing object field data")
    document = data.get("document")
    if not isinstance(document, dict):
        raise UserInputError("lark-cli JSON envelope is missing object field data.document")
    content = document.get("content")
    if not isinstance(content, str):
        raise UserInputError("lark-cli JSON envelope is missing string field data.document.content")
    return content


HELP_EPILOG = """
Examples:
  Local XML file:
    python3 doc_word_stat.py --protocol xml /absolute/path/doc.xml

  Local Markdown file:
    python3 doc_word_stat.py --protocol md /absolute/path/doc.md

  Pipe an extracted local file:
    cat /absolute/path/doc.xml | python3 doc_word_stat.py --protocol xml --pretty

  Lark CLI XML fetch, JSON envelope output:
    lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\
      | python3 doc_word_stat.py --protocol xml --lark-json --pretty

  Lark CLI Markdown fetch, raw content output:
    lark-cli docs +fetch --doc "$URL" --doc-format markdown \\
      | python3 doc_word_stat.py --protocol md

  Strict integration for agents or automation:
    lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\
      | python3 doc_word_stat.py --protocol xml --lark-json --fail-on-unsupported --fail-on-unknown
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Count semantic words and visible characters in Lark Docs XML or Markdown.",
        epilog=HELP_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {VERSION}",
    )
    parser.add_argument(
        "input",
        nargs="?",
        default="-",
        help="input file path, or '-' / omitted for stdin",
    )
    parser.add_argument(
        "--protocol",
        choices=("xml", "md"),
        required=True,
        help="input protocol produced by docs +fetch",
    )
    parser.add_argument(
        "--pretty",
        action="store_true",
        help="pretty-print JSON output",
    )
    parser.add_argument(
        "--segments",
        action="store_true",
        help="include extracted text segments for debugging",
    )
    parser.add_argument(
        "--lark-json",
        action="store_true",
        help="read lark-cli docs +fetch JSON and count data.document.content",
    )
    parser.add_argument(
        "--resource-texts",
        help='optional JSON object mapping resource keys to visible text, e.g. {"task:<task-id>": "title"}',
    )
    parser.add_argument(
        "--fail-on-unsupported",
        action="store_true",
        help="exit with code 2 when unsupported_blocks is non-empty",
    )
    parser.add_argument(
        "--fail-on-unknown",
        action="store_true",
        help="exit with code 3 when unknown XML/Markdown block types are encountered",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    source = read_input(args.input)
    if args.lark_json:
        try:
            source = extract_lark_json_content(source)
        except UserInputError as exc:
            print(f"error: {exc}", file=sys.stderr)
            return 1

    if args.protocol == "xml":
        try:
            blocks = parse_xml(source)
        except (ET.ParseError, UserInputError) as exc:
            print(f"error: could not parse XML input: {exc}", file=sys.stderr)
            return 1
    else:
        blocks = parse_markdown(source)

    ctx = ExtractContext(resource_texts=read_resource_texts(args.resource_texts))
    registry = default_registry()
    segments = []
    for block in blocks:
        segments.extend(registry.extract(block, ctx))

    stats = Counter().count_segments(segments)
    payload = stats.to_dict()
    payload["protocol"] = args.protocol
    payload["unknown_blocks"] = [item.to_dict() for item in ctx.unknown_blocks]
    payload["unsupported_blocks"] = [
        item.to_dict() for item in ctx.unknown_blocks if item.action == "unsupported_resource"
    ]
    payload["diagnostics"] = build_diagnostics(ctx.unknown_blocks)
    if args.segments:
        payload["segments"] = [segment.to_dict() for segment in segments]

    indent = 2 if args.pretty else None
    print(json.dumps(payload, ensure_ascii=False, indent=indent, sort_keys=args.pretty))
    if args.fail_on_unsupported and payload["unsupported_blocks"]:
        return 2
    if args.fail_on_unknown and payload["diagnostics"]["has_unknown"]:
        return 3
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

