"""
Google Docs API client for g-gremlin.

Provides document creation, reading, and updating capabilities using the
existing Google OAuth authentication from google_client.py.

Supports markdown-to-Docs formatting conversion for:
- Headers: # ## ### (HEADING_1, HEADING_2, HEADING_3)
- Bold: **text**
- Italic: *text* or _text_
- Bullet lists: - item (with nested indentation support)
- Numbered lists: 1. item (with nested indentation support)
- Inline code: `code` (Consolas font + gray background)
- Horizontal rules: ---
- Code blocks: ```python ... ``` (Consolas font + gray background)
- Tables: |col1|col2| with header styling (light blue background)
- Screenshot placeholders: [Screenshot: description] (yellow callout box)

API Documentation: https://developers.google.com/docs/api
"""

from __future__ import annotations

import logging
import re
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple

from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError

from g_gremlin.google_client import load_credentials

logger = logging.getLogger(__name__)


class ParagraphStyle(Enum):
    """Google Docs paragraph styles."""

    NORMAL = "NORMAL_TEXT"
    HEADING_1 = "HEADING_1"
    HEADING_2 = "HEADING_2"
    HEADING_3 = "HEADING_3"
    HEADING_4 = "HEADING_4"
    HEADING_5 = "HEADING_5"
    HEADING_6 = "HEADING_6"


@dataclass
class TextSegment:
    """A segment of text with formatting info."""

    text: str
    bold: bool = False
    italic: bool = False
    code: bool = False
    link_url: Optional[str] = None
    start_index: int = 0  # Filled in during insertion
    end_index: int = 0


@dataclass
class TableCell:
    """A cell in a markdown table."""

    content: str
    segments: List[TextSegment] = field(default_factory=list)


@dataclass
class TableRow:
    """A row in a markdown table."""

    cells: List[TableCell]
    is_header: bool = False


@dataclass
class MarkdownTable:
    """A parsed markdown table."""

    rows: List[TableRow]
    column_count: int
    column_alignments: List[str] = field(default_factory=list)


@dataclass
class CodeBlock:
    """A fenced code block."""

    content: str
    language: Optional[str] = None


@dataclass
class ScreenshotPlaceholder:
    """A screenshot placeholder."""

    description: str


@dataclass
class FormattedParagraph:
    """A paragraph with style and text segments."""

    segments: List[TextSegment]
    style: ParagraphStyle = ParagraphStyle.NORMAL
    is_bullet: bool = False
    is_numbered: bool = False
    is_hr: bool = False
    indent_level: int = 0
    table: Optional[MarkdownTable] = None
    code_block: Optional[CodeBlock] = None
    placeholder: Optional[ScreenshotPlaceholder] = None
    line_number: int = 0


@dataclass
class MarkdownParseWarning:
    """Warning emitted while parsing markdown."""

    code: str
    message: str
    line: int
    column: int = 1

    def to_dict(self) -> Dict[str, Any]:
        return {
            "code": self.code,
            "message": self.message,
            "line": self.line,
            "column": self.column,
        }


@dataclass
class MarkdownParseReport:
    """Structured markdown parse diagnostics."""

    mode: str
    total_lines: int
    is_markdown: bool
    block_counts: Dict[str, int]
    warnings: List[MarkdownParseWarning] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "mode": self.mode,
            "total_lines": self.total_lines,
            "is_markdown": self.is_markdown,
            "block_counts": dict(self.block_counts),
            "warnings": [warning.to_dict() for warning in self.warnings],
        }


@dataclass
class MarkdownParseResult:
    """Markdown parse result with paragraphs and diagnostics."""

    paragraphs: List[FormattedParagraph]
    report: MarkdownParseReport


class MarkdownValidationError(ValueError):
    """Raised when strict markdown validation fails."""

    def __init__(self, warnings: List[MarkdownParseWarning]):
        summary = "; ".join(
            f"L{warning.line}:{warning.column} {warning.code} - {warning.message}"
            for warning in warnings[:10]
        )
        if len(warnings) > 10:
            summary = f"{summary}; ... and {len(warnings) - 10} more"
        super().__init__(summary)
        self.warnings = warnings


class MarkdownParser:
    """Parse markdown into formatted paragraphs for Google Docs."""

    VALID_MODES = {"strict", "extended"}
    BLOCK_TYPE_KEYS = (
        "heading",
        "paragraph",
        "bullet_list",
        "numbered_list",
        "table",
        "code_block",
        "placeholder",
        "horizontal_rule",
        "blank",
    )

    # Block patterns
    HEADER_PATTERN = re.compile(r"^(#{1,6})\s+(.+)$")
    HR_PATTERN = re.compile(r"^-{3,}$|^\*{3,}$|^_{3,}$")
    NESTED_BULLET_PATTERN = re.compile(r"^(\s*)[-*]\s+(.+)$")
    NESTED_NUMBERED_PATTERN = re.compile(r"^(\s*)(\d+)\.\s+(.+)$")
    CODE_BLOCK_START = re.compile(r"^```(\w*)$")
    CODE_BLOCK_END = re.compile(r"^```$")
    TABLE_ROW_PATTERN = re.compile(r"^\|(.+)\|$")
    TABLE_ALIGNMENT_CELL_PATTERN = re.compile(r"^:?-{3,}:?$")
    PSEUDO_TABLE_PATTERN = re.compile(r"^\s*[^|]+\|[^|]+\|.+$")
    SCREENSHOT_PATTERN = re.compile(r"^\[Screenshot:\s*(.+?)\]$")

    # Inline patterns
    IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
    LINK_PATTERN = re.compile(r"(?<!!)\[([^\]]+)\]\(([^)]+)\)")
    BOLD_PATTERN = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
    ITALIC_PATTERN = re.compile(
        r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)"
    )
    CODE_PATTERN = re.compile(r"`([^`]+)`")

    # Unsupported pattern diagnostics
    BLOCKQUOTE_PATTERN = re.compile(r"^\s*>")
    TASK_LIST_PATTERN = re.compile(r"^\s*[-*]\s+\[[ xX]\]\s+")
    FOOTNOTE_PATTERN = re.compile(r"^\s*\[\^[^\]]+\]:")
    HTML_TAG_PATTERN = re.compile(r"<[A-Za-z][^>]*>")
    REF_LINK_PATTERN = re.compile(r"\[[^\]]+\]\[[^\]]+\]")

    def __init__(self, mode: str = "extended"):
        normalized_mode = (mode or "extended").strip().lower()
        if normalized_mode not in self.VALID_MODES:
            raise ValueError(
                f"Unsupported markdown parser mode '{mode}'. "
                f"Expected one of: {', '.join(sorted(self.VALID_MODES))}"
            )
        self.mode = normalized_mode

    def parse(self, markdown: str) -> List[FormattedParagraph]:
        """Parse markdown text into formatted paragraphs."""
        return self.parse_with_report(markdown).paragraphs

    def parse_with_report(
        self, markdown: str, force_markdown: bool = False
    ) -> MarkdownParseResult:
        """Parse markdown and emit diagnostics."""
        lines = markdown.split("\n")
        paragraphs: List[FormattedParagraph] = []
        warnings: List[MarkdownParseWarning] = []
        block_counts = {key: 0 for key in self.BLOCK_TYPE_KEYS}
        idx = 0

        while idx < len(lines):
            line = lines[idx]
            line_number = idx + 1

            if self.CODE_BLOCK_START.match(line):
                code_block, idx = self._parse_code_block(lines, idx)
                paragraphs.append(
                    FormattedParagraph(
                        segments=[],
                        code_block=code_block,
                        line_number=line_number,
                    )
                )
                block_counts["code_block"] += 1
                continue

            if self.TABLE_ROW_PATTERN.match(line.strip()):
                table, next_idx, table_warnings = self._parse_table(lines, idx)
                warnings.extend(table_warnings)
                if table is not None:
                    paragraphs.append(
                        FormattedParagraph(
                            segments=[],
                            table=table,
                            line_number=line_number,
                        )
                    )
                    block_counts["table"] += 1
                    idx = next_idx
                    continue

                # Flatten malformed table block as regular paragraphs.
                for fallback_idx in range(idx, next_idx):
                    normalized_line, line_warnings = self._normalize_line(
                        lines[fallback_idx], fallback_idx + 1
                    )
                    warnings.extend(line_warnings)
                    paragraph = self._parse_line(normalized_line)
                    paragraph.line_number = fallback_idx + 1
                    paragraphs.append(paragraph)
                    self._increment_block_counts(paragraph, block_counts)
                idx = next_idx
                continue

            normalized_line, line_warnings = self._normalize_line(line, line_number)
            warnings.extend(line_warnings)

            placeholder_match = self.SCREENSHOT_PATTERN.match(normalized_line.strip())
            if placeholder_match:
                paragraphs.append(
                    FormattedParagraph(
                        segments=[],
                        placeholder=ScreenshotPlaceholder(placeholder_match.group(1)),
                        line_number=line_number,
                    )
                )
                block_counts["placeholder"] += 1
                idx += 1
                continue

            para = self._parse_line(normalized_line)
            para.line_number = line_number
            paragraphs.append(para)
            self._increment_block_counts(para, block_counts)
            idx += 1

        report = MarkdownParseReport(
            mode=self.mode,
            total_lines=len(lines),
            is_markdown=force_markdown or self.looks_like_markdown(markdown),
            block_counts=block_counts,
            warnings=warnings,
        )

        if self.mode == "strict" and warnings:
            raise MarkdownValidationError(warnings)

        # Informational warning about unsupported tables — added after strict
        # check so it doesn't block strict mode validation.
        table_count = block_counts.get("table", 0)
        if table_count > 0:
            warnings.append(MarkdownParseWarning(
                code="table_rendering_unsupported",
                message=(
                    f"{table_count} table(s) detected. Table rendering is not yet supported "
                    "— table content will be skipped. Convert to bullet lists as a workaround."
                ),
                line=0,
            ))

        return MarkdownParseResult(paragraphs=paragraphs, report=report)

    def _parse_line(self, line: str) -> FormattedParagraph:
        """Parse a single line into a formatted paragraph."""
        if self.HR_PATTERN.match(line.strip()):
            return FormattedParagraph(
                segments=[TextSegment("-" * 50)],
                is_hr=True,
            )

        header_match = self.HEADER_PATTERN.match(line)
        if header_match:
            level = len(header_match.group(1))
            content = header_match.group(2)
            style = ParagraphStyle[f"HEADING_{min(level, 6)}"]
            segments = self._parse_inline_formatting(content)
            return FormattedParagraph(segments=segments, style=style)

        bullet_match = self.NESTED_BULLET_PATTERN.match(line)
        if bullet_match:
            leading_ws = bullet_match.group(1)
            content = bullet_match.group(2)
            indent_level = self._get_indent_level(leading_ws)
            segments = self._parse_inline_formatting(content)
            return FormattedParagraph(
                segments=segments, is_bullet=True, indent_level=indent_level
            )

        numbered_match = self.NESTED_NUMBERED_PATTERN.match(line)
        if numbered_match:
            leading_ws = numbered_match.group(1)
            content = numbered_match.group(3)
            indent_level = self._get_indent_level(leading_ws)
            segments = self._parse_inline_formatting(content)
            return FormattedParagraph(
                segments=segments, is_numbered=True, indent_level=indent_level
            )

        segments = self._parse_inline_formatting(line)
        return FormattedParagraph(segments=segments)

    def _parse_inline_formatting(self, text: str) -> List[TextSegment]:
        """Parse inline formatting (bold, italic, code, links) from text."""
        if not text:
            return [TextSegment("")]

        segments: List[TextSegment] = []
        candidates: List[Tuple[int, int, int, str, Tuple[str, ...]]] = []

        for match in self.CODE_PATTERN.finditer(text):
            candidates.append((match.start(), match.end(), 0, "code", (match.group(1),)))

        for match in self.LINK_PATTERN.finditer(text):
            candidates.append(
                (
                    match.start(),
                    match.end(),
                    1,
                    "link",
                    (match.group(1), match.group(2).strip()),
                )
            )

        for match in self.BOLD_PATTERN.finditer(text):
            content = match.group(1) if match.group(1) is not None else match.group(2)
            if content is not None:
                candidates.append((match.start(), match.end(), 2, "bold", (content,)))

        for match in self.ITALIC_PATTERN.finditer(text):
            content = match.group(1) if match.group(1) is not None else match.group(2)
            if content is not None:
                candidates.append((match.start(), match.end(), 3, "italic", (content,)))

        candidates.sort(
            key=lambda candidate: (candidate[0], candidate[2], -(candidate[1] - candidate[0]))
        )

        selected: List[Tuple[int, int, str, Tuple[str, ...]]] = []
        occupied: List[Tuple[int, int, str]] = []
        for start, end, _, kind, values in candidates:
            if not self._overlaps_any(start, end, occupied):
                selected.append((start, end, kind, values))
                occupied.append((start, end, kind))
        selected.sort(key=lambda candidate: candidate[0])

        pos = 0
        for start, end, kind, values in selected:
            if start > pos:
                segments.append(TextSegment(text[pos:start]))

            if kind == "code":
                segments.append(TextSegment(values[0], code=True))
            elif kind == "link":
                segments.append(TextSegment(values[0], link_url=values[1]))
            elif kind == "bold":
                segments.append(TextSegment(values[0], bold=True))
            elif kind == "italic":
                segments.append(TextSegment(values[0], italic=True))

            pos = end

        if pos < len(text):
            segments.append(TextSegment(text[pos:]))

        return segments if segments else [TextSegment("")]

    def _overlaps_any(
        self, start: int, end: int, ranges: List[Tuple[int, int, str]]
    ) -> bool:
        """Check if a range overlaps with any existing ranges."""
        for r_start, r_end, _ in ranges:
            if start < r_end and end > r_start:
                return True
        return False

    def _get_indent_level(self, leading_whitespace: str) -> int:
        """Convert leading whitespace to indent level (2 spaces = 1 level)."""
        spaces = len(leading_whitespace.replace("\t", "    "))
        return spaces // 2

    def _parse_code_block(
        self, lines: List[str], start_idx: int
    ) -> Tuple[CodeBlock, int]:
        """Parse a fenced code block starting at start_idx."""
        opening_match = self.CODE_BLOCK_START.match(lines[start_idx])
        language = opening_match.group(1) if opening_match and opening_match.group(1) else None

        content_lines: List[str] = []
        idx = start_idx + 1
        while idx < len(lines):
            if self.CODE_BLOCK_END.match(lines[idx]):
                idx += 1
                break
            content_lines.append(lines[idx])
            idx += 1

        return CodeBlock(content="\n".join(content_lines), language=language), idx

    def _parse_table(
        self, lines: List[str], start_idx: int
    ) -> Tuple[Optional[MarkdownTable], int, List[MarkdownParseWarning]]:
        """Parse a markdown table starting at start_idx."""
        warnings: List[MarkdownParseWarning] = []
        idx = start_idx
        while idx < len(lines) and self.TABLE_ROW_PATTERN.match(lines[idx].strip()):
            idx += 1
        table_end = idx

        if table_end - start_idx < 2:
            warnings.append(
                MarkdownParseWarning(
                    code="table_missing_separator",
                    message="Markdown table requires a header row and separator row.",
                    line=start_idx + 1,
                )
            )
            return None, table_end, warnings

        header_cells = self._split_table_cells(lines[start_idx].strip())
        separator_cells = self._split_table_cells(lines[start_idx + 1].strip())

        if not header_cells:
            warnings.append(
                MarkdownParseWarning(
                    code="table_missing_header",
                    message="Table header row is empty.",
                    line=start_idx + 1,
                )
            )
            return None, table_end, warnings

        if len(header_cells) != len(separator_cells):
            warnings.append(
                MarkdownParseWarning(
                    code="table_separator_mismatch",
                    message=(
                        f"Header has {len(header_cells)} column(s) but separator has "
                        f"{len(separator_cells)} column(s)."
                    ),
                    line=start_idx + 2,
                )
            )
            return None, table_end, warnings

        alignments: List[str] = []
        for marker in separator_cells:
            alignment = self._parse_alignment_marker(marker)
            if alignment is None:
                warnings.append(
                    MarkdownParseWarning(
                        code="table_invalid_alignment",
                        message=f"Invalid table separator marker '{marker}'.",
                        line=start_idx + 2,
                    )
                )
                return None, table_end, warnings
            alignments.append(alignment)

        rows = [self._build_table_row(header_cells, is_header=True)]
        for row_idx in range(start_idx + 2, table_end):
            row_cells = self._split_table_cells(lines[row_idx].strip())
            if len(row_cells) != len(header_cells):
                warnings.append(
                    MarkdownParseWarning(
                        code="table_column_mismatch",
                        message=(
                            f"Row has {len(row_cells)} column(s); expected {len(header_cells)}."
                        ),
                        line=row_idx + 1,
                    )
                )
                return None, table_end, warnings
            rows.append(self._build_table_row(row_cells, is_header=False))

        table = MarkdownTable(
            rows=rows,
            column_count=len(header_cells),
            column_alignments=alignments,
        )
        if not self._is_valid_table(table):
            warnings.append(
                MarkdownParseWarning(
                    code="table_invalid",
                    message="Invalid table: inconsistent column counts.",
                    line=start_idx + 1,
                )
            )
            return None, table_end, warnings

        return table, table_end, warnings

    def _is_valid_table(self, table: MarkdownTable) -> bool:
        """Validate table has consistent column count."""
        if not table.rows:
            return False
        col_count = table.column_count
        if col_count == 0:
            return False
        if table.column_alignments and len(table.column_alignments) != col_count:
            return False
        return all(len(row.cells) == col_count for row in table.rows)

    def _increment_block_counts(
        self, paragraph: FormattedParagraph, block_counts: Dict[str, int]
    ) -> None:
        """Update block type counts for diagnostics."""
        if paragraph.is_hr:
            block_counts["horizontal_rule"] += 1
            return
        if paragraph.style != ParagraphStyle.NORMAL:
            block_counts["heading"] += 1
            return
        if paragraph.is_bullet:
            block_counts["bullet_list"] += 1
            return
        if paragraph.is_numbered:
            block_counts["numbered_list"] += 1
            return
        if paragraph.placeholder is not None:
            block_counts["placeholder"] += 1
            return
        text = "".join(segment.text for segment in paragraph.segments)
        if text.strip():
            block_counts["paragraph"] += 1
        else:
            block_counts["blank"] += 1

    def _normalize_line(
        self, line: str, line_number: int
    ) -> Tuple[str, List[MarkdownParseWarning]]:
        """Normalize inline markdown and emit warnings for unsupported constructs."""
        warnings: List[MarkdownParseWarning] = []
        normalized = line
        stripped = normalized.strip()

        if self.BLOCKQUOTE_PATTERN.match(normalized):
            warnings.append(
                MarkdownParseWarning(
                    code="unsupported_blockquote",
                    message="Blockquotes are flattened to plain text.",
                    line=line_number,
                )
            )
            while stripped.startswith(">"):
                stripped = stripped[1:].lstrip()
            normalized = stripped

        if self.TASK_LIST_PATTERN.match(normalized):
            warnings.append(
                MarkdownParseWarning(
                    code="unsupported_task_list",
                    message="Task list syntax is not preserved; rendered as bullet text.",
                    line=line_number,
                )
            )

        if self.FOOTNOTE_PATTERN.match(stripped):
            warnings.append(
                MarkdownParseWarning(
                    code="unsupported_footnote",
                    message="Footnote definitions are rendered as plain text.",
                    line=line_number,
                )
            )

        if self.HTML_TAG_PATTERN.search(normalized):
            warnings.append(
                MarkdownParseWarning(
                    code="unsupported_html",
                    message="Inline HTML is rendered as plain text.",
                    line=line_number,
                )
            )

        if self.REF_LINK_PATTERN.search(normalized):
            warnings.append(
                MarkdownParseWarning(
                    code="unsupported_reference_link",
                    message="Reference-style links are rendered as plain text.",
                    line=line_number,
                )
            )

        if (
            "|" in normalized
            and not self.TABLE_ROW_PATTERN.match(stripped)
            and self.PSEUDO_TABLE_PATTERN.match(normalized)
        ):
            warnings.append(
                MarkdownParseWarning(
                    code="pseudo_table_flattened",
                    message=(
                        "Pseudo-table line detected but it is not a valid markdown table row; "
                        "line will be rendered as plain text."
                    ),
                    line=line_number,
                )
            )

        image_matches = list(self.IMAGE_PATTERN.finditer(normalized))
        for match in image_matches:
            alt_text = (match.group(1) or "").strip() or "image"
            warnings.append(
                MarkdownParseWarning(
                    code="unsupported_image_embed",
                    message=f"Image embed skipped; rendering alt text '{alt_text}'.",
                    line=line_number,
                    column=match.start() + 1,
                )
            )
        if image_matches:
            normalized = self.IMAGE_PATTERN.sub(
                lambda matched: f"[Image: {(matched.group(1) or '').strip() or 'image'}]",
                normalized,
            )

        return normalized, warnings

    def _split_table_cells(self, line: str) -> List[str]:
        """Split table row into cells while honoring escaped pipes."""
        row = line.strip()
        if row.startswith("|"):
            row = row[1:]
        if row.endswith("|"):
            row = row[:-1]

        cells: List[str] = []
        cell_buffer: List[str] = []
        idx = 0
        while idx < len(row):
            char = row[idx]
            if char == "\\" and idx + 1 < len(row):
                next_char = row[idx + 1]
                if next_char in {"|", "\\"}:
                    cell_buffer.append(next_char)
                    idx += 2
                    continue
            if char == "|":
                cells.append("".join(cell_buffer).strip())
                cell_buffer = []
                idx += 1
                continue
            cell_buffer.append(char)
            idx += 1
        cells.append("".join(cell_buffer).strip())
        return cells

    def _parse_alignment_marker(self, marker: str) -> Optional[str]:
        """Parse a markdown table alignment marker."""
        normalized = marker.strip()
        if not self.TABLE_ALIGNMENT_CELL_PATTERN.match(normalized):
            return None
        if normalized.startswith(":") and normalized.endswith(":"):
            return "center"
        if normalized.endswith(":"):
            return "right"
        return "left"

    def _build_table_row(self, row_cells: List[str], is_header: bool) -> TableRow:
        """Build a table row with parsed inline formatting."""
        cells: List[TableCell] = []
        for raw_cell in row_cells:
            normalized_cell = self._normalize_table_cell_text(raw_cell.strip())
            segments = self._parse_inline_formatting(normalized_cell)
            plain_text = "".join(segment.text for segment in segments)
            cells.append(TableCell(content=plain_text, segments=segments))
        return TableRow(cells=cells, is_header=is_header)

    def _normalize_table_cell_text(self, text: str) -> str:
        """Normalize common multiline cell markers."""
        return re.sub(r"(?i)<br\s*/?>", "\n", text)

    @classmethod
    def looks_like_markdown(cls, content: str) -> bool:
        """Detect whether content likely contains markdown syntax."""
        markdown_indicators = [
            r"^#{1,6}\s",
            r"\*\*.+\*\*",
            r"^\s*[-*]\s",
            r"^\s*\d+\.\s",
            r"^-{3,}$",
            r"`.+`",
            r"^```",
            r"^\|.+\|$",
            r"^\[Screenshot:",
            r"(?<!!)\[[^\]]+\]\([^)]+\)",
            r"!\[[^\]]*\]\([^)]+\)",
        ]
        return any(
            re.search(pattern, content, re.MULTILINE)
            for pattern in markdown_indicators
        )

# Docs API scope - should be added to google_client.py SCOPES
DOCS_SCOPE = "https://www.googleapis.com/auth/documents"


@dataclass
class GoogleDoc:
    """Representation of a Google Doc."""

    id: str
    title: str
    url: Optional[str] = None
    revision_id: Optional[str] = None
    body_content: str = ""
    table_warnings: List[str] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        result: Dict[str, Any] = {
            "id": self.id,
            "title": self.title,
            "url": self.url or f"https://docs.google.com/document/d/{self.id}/edit",
            "revision_id": self.revision_id,
            "body_content": self.body_content,
        }
        if self.table_warnings:
            result["table_warnings"] = self.table_warnings
        return result


class GoogleDocsError(RuntimeError):
    """Raised when Google Docs operations fail."""


class GoogleDocsClient:
    """Google Docs API client."""

    SUPPORTED_CONTENT_FORMATS = {"auto", "text", "markdown"}
    SUPPORTED_MARKDOWN_MODES = {"strict", "extended"}

    def __init__(self, credentials=None) -> None:
        """
        Initialize the Google Docs client.

        Args:
            credentials: Optional Google credentials. If not provided,
                         will use load_credentials() from google_client.
        """
        self._credentials = credentials
        self._service: Optional[Resource] = None
        self.table_warnings: List[str] = []

    def _get_service(self) -> Resource:
        """Get or create the Docs API service."""
        if self._service is None:
            creds = self._credentials or load_credentials()
            self._service = build("docs", "v1", credentials=creds, cache_discovery=False)
        return self._service

    def build_markdown_report(
        self,
        content: str,
        content_format: str = "auto",
        markdown_mode: str = "extended",
    ) -> MarkdownParseReport:
        """Build markdown diagnostics without writing to Google Docs."""
        _, parse_result = self._prepare_content_rendering(
            content=content,
            format_markdown=True,
            content_format=content_format,
            markdown_mode=markdown_mode,
        )

        if parse_result is None:
            lines = content.split("\n")
            return MarkdownParseReport(
                mode=markdown_mode.lower(),
                total_lines=len(lines),
                is_markdown=False,
                block_counts={
                    "heading": 0,
                    "paragraph": sum(1 for line in lines if line.strip()),
                    "bullet_list": 0,
                    "numbered_list": 0,
                    "table": 0,
                    "code_block": 0,
                    "placeholder": 0,
                    "horizontal_rule": 0,
                    "blank": sum(1 for line in lines if not line.strip()),
                },
                warnings=[],
            )
        return parse_result.report

    def _normalize_content_format(self, content_format: str) -> str:
        normalized = (content_format or "auto").strip().lower()
        if normalized not in self.SUPPORTED_CONTENT_FORMATS:
            raise GoogleDocsError(
                f"Unsupported content format '{content_format}'. "
                f"Expected one of: {', '.join(sorted(self.SUPPORTED_CONTENT_FORMATS))}"
            )
        return normalized

    def _normalize_markdown_mode(self, markdown_mode: str) -> str:
        normalized = (markdown_mode or "extended").strip().lower()
        if normalized not in self.SUPPORTED_MARKDOWN_MODES:
            raise GoogleDocsError(
                f"Unsupported markdown mode '{markdown_mode}'. "
                f"Expected one of: {', '.join(sorted(self.SUPPORTED_MARKDOWN_MODES))}"
            )
        return normalized

    def _prepare_content_rendering(
        self,
        content: str,
        format_markdown: bool,
        content_format: str,
        markdown_mode: str,
    ) -> Tuple[bool, Optional[MarkdownParseResult]]:
        """Decide markdown rendering behavior and pre-parse when applicable."""
        normalized_format = self._normalize_content_format(content_format)
        normalized_mode = self._normalize_markdown_mode(markdown_mode)

        if not format_markdown or normalized_format == "text":
            return False, None

        force_markdown = normalized_format == "markdown"
        should_parse = force_markdown or (
            normalized_format == "auto" and self._is_markdown(content)
        )
        if not should_parse:
            return False, None

        parser = MarkdownParser(mode=normalized_mode)
        try:
            parse_result = parser.parse_with_report(content, force_markdown=True)
        except MarkdownValidationError as exc:
            raise GoogleDocsError(
                "Strict markdown validation failed: "
                + "; ".join(
                    f"L{warning.line}:{warning.column} {warning.code}"
                    for warning in exc.warnings[:10]
                )
            ) from exc

        return True, parse_result

    def create_document(
        self,
        title: str,
        content: Optional[str] = None,
        folder_id: Optional[str] = None,
        format_markdown: bool = True,
        content_format: str = "auto",
        markdown_mode: str = "extended",
    ) -> GoogleDoc:
        """
        Create a new Google Doc.

        Args:
            title: Document title
            content: Optional initial content (plain text or markdown)
            folder_id: Optional Drive folder ID to create document in
                       (requires Drive API, not implemented yet)
            format_markdown: If True, detect and apply markdown formatting.
            content_format: auto, markdown, or text.
            markdown_mode: strict or extended.

        Returns:
            GoogleDoc with the created document info
        """
        service = self._get_service()

        try:
            # Create the document
            doc = service.documents().create(body={"title": title}).execute()
            doc_id = doc.get("documentId")

            if not doc_id:
                raise GoogleDocsError("Failed to get document ID from response")

            # Add content if provided
            if content:
                use_formatting, parse_result = self._prepare_content_rendering(
                    content=content,
                    format_markdown=format_markdown,
                    content_format=content_format,
                    markdown_mode=markdown_mode,
                )
                if use_formatting:
                    self._insert_formatted_text(
                        doc_id, content, parse_result=parse_result
                    )
                else:
                    self._insert_text(doc_id, content)

            return GoogleDoc(
                id=doc_id,
                title=title,
                url=f"https://docs.google.com/document/d/{doc_id}/edit",
                revision_id=doc.get("revisionId"),
                body_content=content or "",
            )

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to create document: {exc}") from exc

    def get_document(self, doc_id: str) -> GoogleDoc:
        """
        Get a document by ID.

        Args:
            doc_id: Document ID or URL

        Returns:
            GoogleDoc with document info and content
        """
        doc_id = self._extract_doc_id(doc_id)
        service = self._get_service()

        try:
            doc = service.documents().get(documentId=doc_id).execute()

            # Extract text content from the document body
            body_content = self._extract_text_content(doc)

            return GoogleDoc(
                id=doc_id,
                title=doc.get("title", ""),
                url=f"https://docs.google.com/document/d/{doc_id}/edit",
                revision_id=doc.get("revisionId"),
                body_content=body_content,
            )

        except HttpError as exc:
            if exc.resp.status == 404:
                raise GoogleDocsError(f"Document not found: {doc_id}") from exc
            raise GoogleDocsError(f"Failed to get document: {exc}") from exc

    def update_document(
        self,
        doc_id: str,
        content: str,
        replace: bool = True,
        format_markdown: bool = True,
        content_format: str = "auto",
        markdown_mode: str = "extended",
    ) -> GoogleDoc:
        """
        Update a document's content.

        Args:
            doc_id: Document ID or URL
            content: New content (plain text or markdown)
            replace: If True, replace all content. If False, append.
            format_markdown: If True, detect and apply markdown formatting.
            content_format: auto, markdown, or text.
            markdown_mode: strict or extended.

        Returns:
            GoogleDoc with updated document info
        """
        doc_id = self._extract_doc_id(doc_id)

        use_formatting, parse_result = self._prepare_content_rendering(
            content=content,
            format_markdown=format_markdown,
            content_format=content_format,
            markdown_mode=markdown_mode,
        )

        if replace:
            # Get current document to find content range
            doc = self.get_document(doc_id)
            if doc.body_content:
                self._clear_content(doc_id)

            if use_formatting:
                self._insert_formatted_text(doc_id, content, parse_result=parse_result)
            else:
                self._insert_text(doc_id, content)
        else:
            if use_formatting:
                self._append_formatted_text(
                    doc_id, content, parse_result=parse_result
                )
            else:
                self._append_text(doc_id, content)

        doc = self.get_document(doc_id)
        doc.table_warnings = list(self.table_warnings)
        return doc

    def append_content(
        self,
        doc_id: str,
        content: str,
        add_newline: bool = True,
        format_markdown: bool = True,
        content_format: str = "auto",
        markdown_mode: str = "extended",
    ) -> GoogleDoc:
        """
        Append content to a document.

        Args:
            doc_id: Document ID or URL
            content: Content to append (plain text or markdown)
            add_newline: If True, add newline before content
            format_markdown: If True, detect and apply markdown formatting.
            content_format: auto, markdown, or text.
            markdown_mode: strict or extended.

        Returns:
            GoogleDoc with updated document info
        """
        doc_id = self._extract_doc_id(doc_id)

        if add_newline:
            content = "\n" + content

        use_formatting, parse_result = self._prepare_content_rendering(
            content=content,
            format_markdown=format_markdown,
            content_format=content_format,
            markdown_mode=markdown_mode,
        )
        if use_formatting:
            self._append_formatted_text(doc_id, content, parse_result=parse_result)
        else:
            self._append_text(doc_id, content)

        doc = self.get_document(doc_id)
        doc.table_warnings = list(self.table_warnings)
        return doc

    def insert_text(
        self,
        doc_id: str,
        text: str,
        index: int = 1,
    ) -> GoogleDoc:
        """
        Insert text at a specific index.

        Args:
            doc_id: Document ID or URL
            text: Text to insert
            index: Character index (1 is start of document)

        Returns:
            GoogleDoc with updated document info
        """
        doc_id = self._extract_doc_id(doc_id)
        self._insert_text(doc_id, text, index)
        return self.get_document(doc_id)

    def _insert_text(self, doc_id: str, text: str, index: int = 1) -> None:
        """Insert text at specified index using batchUpdate."""
        service = self._get_service()

        requests = [
            {
                "insertText": {
                    "location": {"index": index},
                    "text": text,
                }
            }
        ]

        try:
            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": requests},
            ).execute()
        except HttpError as exc:
            raise GoogleDocsError(f"Failed to insert text: {exc}") from exc

    def _insert_formatted_text(
        self,
        doc_id: str,
        content: str,
        index: int = 1,
        parse_result: Optional[MarkdownParseResult] = None,
    ) -> None:
        """
        Insert text with markdown formatting applied.

        Parses markdown and applies appropriate Google Docs styles.
        Handles special elements (tables, code blocks, placeholders) individually.
        """
        if parse_result is None:
            parser = MarkdownParser()
            parse_result = parser.parse_with_report(content, force_markdown=True)
        paragraphs = parse_result.paragraphs

        # Check if we have any special elements that need individual handling.
        # Tables are skipped with a warning (cell population is unreliable),
        # but still require sequential mode so warnings can be accumulated.
        has_special = any(
            para.table or para.code_block or para.placeholder
            for para in paragraphs
        )

        if has_special:
            # Process paragraphs sequentially to handle special elements
            self._insert_formatted_text_sequential(doc_id, paragraphs, index)
        else:
            # Use optimized batch insertion for regular text
            self._insert_formatted_text_batch(doc_id, paragraphs, index)

    def _insert_formatted_text_sequential(
        self, doc_id: str, paragraphs: List[FormattedParagraph], index: int
    ) -> None:
        """Insert paragraphs with batching for regular text and sequential for special elements.

        Strategy: Batch consecutive regular paragraphs together (2 API calls per batch),
        only process special elements (tables, code blocks, placeholders) individually.
        This dramatically reduces API calls vs inserting each paragraph separately.

        Google Docs API quota: 60 writes/minute.
        """
        current_pos = index
        api_calls = 0
        start_time = time.time()

        def wait_for_rate_limit():
            """Wait if we're approaching the rate limit."""
            nonlocal api_calls, start_time
            elapsed = time.time() - start_time
            # Target: 45 calls/min (75% of limit) = 0.75 calls/sec
            if api_calls > 0 and elapsed > 0:
                rate = api_calls / elapsed
                if rate > 0.7:  # Getting close to limit
                    # Wait enough to get back under target rate
                    target_elapsed = api_calls / 0.6  # 36 calls/min target
                    sleep_time = max(0, target_elapsed - elapsed)
                    if sleep_time > 0:
                        logger.debug(f"Rate limiting: {api_calls} calls in {elapsed:.1f}s, sleeping {sleep_time:.1f}s")
                        time.sleep(min(sleep_time, 5.0))  # Cap at 5s

        def handle_rate_limit_error():
            """Reset tracking and wait for quota reset."""
            nonlocal api_calls, start_time
            logger.warning("Rate limited (429), waiting 65s for quota reset")
            time.sleep(65)
            api_calls = 0
            start_time = time.time()

        # Group paragraphs: collect consecutive regular paragraphs between special elements
        i = 0
        while i < len(paragraphs):
            para = paragraphs[i]

            # Check for special element
            if para.table or para.code_block or para.placeholder:
                if para.table:
                    # Table cell population is unreliable — skip insertion
                    # and warn the user rather than creating empty table shells.
                    row_count = len(para.table.rows)
                    col_count = para.table.column_count
                    logger.debug(
                        "Skipping table at line %d (%d rows x %d cols): "
                        "cell population not yet reliable",
                        para.line_number, row_count, col_count,
                    )
                    self.table_warnings.append(
                        f"Table at line {para.line_number} ({row_count} rows x {col_count} cols) "
                        "skipped — table rendering is not yet supported. "
                        "Convert to bullet lists as a workaround."
                    )
                    i += 1
                    continue

                wait_for_rate_limit()
                time.sleep(1.0)  # Extra delay before complex operations

                try:
                    if para.code_block:
                        current_pos = self._insert_code_block(doc_id, para.code_block, current_pos)
                        api_calls += 2
                    elif para.placeholder:
                        current_pos = self._insert_placeholder(
                            doc_id, para.placeholder.description, current_pos
                        )
                        api_calls += 2
                except HttpError as exc:
                    if exc.resp.status == 429:
                        handle_rate_limit_error()
                        # Retry
                        if para.code_block:
                            current_pos = self._insert_code_block(doc_id, para.code_block, current_pos)
                        elif para.placeholder:
                            current_pos = self._insert_placeholder(
                                doc_id, para.placeholder.description, current_pos
                            )
                    else:
                        raise
                i += 1
            else:
                # Collect consecutive regular paragraphs
                regular_batch: List[FormattedParagraph] = []
                while i < len(paragraphs):
                    p = paragraphs[i]
                    if p.table or p.code_block or p.placeholder:
                        break
                    regular_batch.append(p)
                    i += 1

                # Batch insert regular paragraphs (2 API calls total for any batch size)
                if regular_batch:
                    wait_for_rate_limit()
                    try:
                        batch_len = self._insert_paragraph_batch(doc_id, regular_batch, current_pos)
                        current_pos += batch_len
                        api_calls += 2  # One batch = 2 API calls (insert + format)
                    except HttpError as exc:
                        if exc.resp.status == 429:
                            handle_rate_limit_error()
                            batch_len = self._insert_paragraph_batch(doc_id, regular_batch, current_pos)
                            current_pos += batch_len
                        else:
                            raise

    def _insert_paragraph_batch(
        self, doc_id: str, paragraphs: List[FormattedParagraph], index: int
    ) -> int:
        """Insert a batch of regular paragraphs efficiently.

        Combines all paragraphs into a single text insertion, then applies
        formatting in a single batch update. This uses only 2 API calls
        regardless of how many paragraphs are in the batch.

        Args:
            doc_id: Document ID
            paragraphs: List of regular FormattedParagraphs (no tables/code blocks/placeholders)
            index: Document index to insert at

        Returns:
            Total length of inserted text
        """
        if not paragraphs:
            return 0

        service = self._get_service()

        # Build combined text and track segment positions
        combined_text = ""
        para_info: List[Dict[str, Any]] = []  # Track para start, end, and formatting

        for para in paragraphs:
            para_start = index + len(combined_text)
            para_text = "".join(seg.text for seg in para.segments) + "\n"
            para_end = para_start + len(para_text) - 1  # Exclude newline for formatting

            # Track segment positions within this paragraph
            seg_infos = []
            seg_pos = para_start
            for seg in para.segments:
                if seg.text:
                    seg_infos.append({
                        "start": seg_pos,
                        "end": seg_pos + len(seg.text),
                        "bold": seg.bold,
                        "italic": seg.italic,
                        "code": seg.code,
                        "link_url": seg.link_url,
                    })
                    seg_pos += len(seg.text)

            para_info.append({
                "start": para_start,
                "end": para_end,
                "text_end": para_start + len(para_text),
                "style": para.style,
                "is_bullet": para.is_bullet,
                "is_numbered": para.is_numbered,
                "is_hr": para.is_hr,
                "indent_level": para.indent_level,
                "segments": seg_infos,
            })
            combined_text += para_text

        if not combined_text:
            return 0

        try:
            # API Call 1: Insert all text at once
            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": [{
                    "insertText": {
                        "location": {"index": index},
                        "text": combined_text,
                    }
                }]},
            ).execute()

            # Build all formatting requests
            formatting_requests: List[Dict[str, Any]] = []

            for pinfo in para_info:
                # Horizontal rule - special handling
                if pinfo["is_hr"]:
                    continue  # HR text is already inserted, no extra formatting needed

                # Text segment formatting (bold, italic, code)
                for seg in pinfo["segments"]:
                    if seg["bold"] or seg["italic"] or seg["code"] or seg["link_url"]:
                        text_style: Dict[str, Any] = {}
                        fields = []

                        if seg["bold"]:
                            text_style["bold"] = True
                            fields.append("bold")
                        if seg["italic"]:
                            text_style["italic"] = True
                            fields.append("italic")
                        if seg["code"]:
                            text_style["weightedFontFamily"] = {
                                "fontFamily": "Consolas",
                                "weight": 400,
                            }
                            text_style["backgroundColor"] = {
                                "color": {"rgbColor": {"red": 0.93, "green": 0.93, "blue": 0.93}}
                            }
                            fields.extend(["weightedFontFamily", "backgroundColor"])
                        if seg["link_url"]:
                            text_style["link"] = {"url": seg["link_url"]}
                            fields.append("link")

                        formatting_requests.append({
                            "updateTextStyle": {
                                "range": {"startIndex": seg["start"], "endIndex": seg["end"]},
                                "textStyle": text_style,
                                "fields": ",".join(fields),
                            }
                        })

                # Paragraph style (headers)
                if pinfo["style"] != ParagraphStyle.NORMAL:
                    formatting_requests.append({
                        "updateParagraphStyle": {
                            "range": {"startIndex": pinfo["start"], "endIndex": pinfo["end"]},
                            "paragraphStyle": {"namedStyleType": pinfo["style"].value},
                            "fields": "namedStyleType",
                        }
                    })

                # Bullet lists
                if pinfo["is_bullet"]:
                    formatting_requests.append({
                        "createParagraphBullets": {
                            "range": {"startIndex": pinfo["start"], "endIndex": pinfo["end"]},
                            "bulletPreset": "BULLET_DISC_CIRCLE_SQUARE",
                        }
                    })
                    if pinfo["indent_level"] > 0:
                        formatting_requests.append({
                            "updateParagraphStyle": {
                                "range": {"startIndex": pinfo["start"], "endIndex": pinfo["end"]},
                                "paragraphStyle": {
                                    "indentFirstLine": {"magnitude": 18 * pinfo["indent_level"], "unit": "PT"},
                                    "indentStart": {"magnitude": 18 * pinfo["indent_level"], "unit": "PT"},
                                },
                                "fields": "indentFirstLine,indentStart",
                            }
                        })

                # Numbered lists
                if pinfo["is_numbered"]:
                    formatting_requests.append({
                        "createParagraphBullets": {
                            "range": {"startIndex": pinfo["start"], "endIndex": pinfo["end"]},
                            "bulletPreset": "NUMBERED_DECIMAL_NESTED",
                        }
                    })
                    if pinfo["indent_level"] > 0:
                        formatting_requests.append({
                            "updateParagraphStyle": {
                                "range": {"startIndex": pinfo["start"], "endIndex": pinfo["end"]},
                                "paragraphStyle": {
                                    "indentFirstLine": {"magnitude": 18 * pinfo["indent_level"], "unit": "PT"},
                                    "indentStart": {"magnitude": 18 * pinfo["indent_level"], "unit": "PT"},
                                },
                                "fields": "indentFirstLine,indentStart",
                            }
                        })

            # API Call 2: Apply all formatting at once
            if formatting_requests:
                service.documents().batchUpdate(
                    documentId=doc_id,
                    body={"requests": formatting_requests},
                ).execute()

            return len(combined_text)

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to insert paragraph batch: {exc}") from exc

    def _insert_single_paragraph(
        self, doc_id: str, para: FormattedParagraph, index: int
    ) -> int:
        """Insert a single formatted paragraph.

        Args:
            doc_id: Document ID
            para: FormattedParagraph to insert
            index: Document index to insert at

        Returns:
            New index position after insertion
        """
        service = self._get_service()

        # Build paragraph text
        para_text = "".join(seg.text for seg in para.segments) + "\n"
        text_len = len(para_text)

        try:
            # Insert text
            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": [{
                    "insertText": {
                        "location": {"index": index},
                        "text": para_text,
                    }
                }]},
            ).execute()

            # Build formatting requests
            formatting_requests: List[Dict[str, Any]] = []
            current_pos = index

            for seg in para.segments:
                seg_len = len(seg.text)
                if seg_len == 0:
                    continue

                seg.start_index = current_pos
                seg.end_index = current_pos + seg_len

                # Apply text styles (bold, italic, code)
                if seg.bold or seg.italic or seg.code or seg.link_url:
                    text_style: Dict[str, Any] = {}
                    fields = []

                    if seg.bold:
                        text_style["bold"] = True
                        fields.append("bold")
                    if seg.italic:
                        text_style["italic"] = True
                        fields.append("italic")
                    if seg.code:
                        # Use monospace font for inline code
                        text_style["weightedFontFamily"] = {
                            "fontFamily": "Consolas",
                            "weight": 400,
                        }
                        text_style["backgroundColor"] = {
                            "color": {"rgbColor": {"red": 0.93, "green": 0.93, "blue": 0.93}}
                        }
                        fields.extend(["weightedFontFamily", "backgroundColor"])
                    if seg.link_url:
                        text_style["link"] = {"url": seg.link_url}
                        fields.append("link")

                    formatting_requests.append({
                        "updateTextStyle": {
                            "range": {
                                "startIndex": seg.start_index,
                                "endIndex": seg.end_index,
                            },
                            "textStyle": text_style,
                            "fields": ",".join(fields),
                        }
                    })

                current_pos += seg_len

            para_end = current_pos  # Before newline

            # Apply paragraph style (headers)
            if para.style != ParagraphStyle.NORMAL:
                formatting_requests.append({
                    "updateParagraphStyle": {
                        "range": {
                            "startIndex": index,
                            "endIndex": para_end,
                        },
                        "paragraphStyle": {
                            "namedStyleType": para.style.value,
                        },
                        "fields": "namedStyleType",
                    }
                })

            # Apply bullet list with indentation
            if para.is_bullet:
                formatting_requests.append({
                    "createParagraphBullets": {
                        "range": {
                            "startIndex": index,
                            "endIndex": para_end,
                        },
                        "bulletPreset": "BULLET_DISC_CIRCLE_SQUARE",
                    }
                })
                # Apply indentation for nested lists
                if para.indent_level > 0:
                    formatting_requests.append({
                        "updateParagraphStyle": {
                            "range": {
                                "startIndex": index,
                                "endIndex": para_end,
                            },
                            "paragraphStyle": {
                                "indentFirstLine": {
                                    "magnitude": 18 * para.indent_level,
                                    "unit": "PT"
                                },
                                "indentStart": {
                                    "magnitude": 18 * para.indent_level,
                                    "unit": "PT"
                                }
                            },
                            "fields": "indentFirstLine,indentStart",
                        }
                    })

            # Apply numbered list with indentation
            if para.is_numbered:
                formatting_requests.append({
                    "createParagraphBullets": {
                        "range": {
                            "startIndex": index,
                            "endIndex": para_end,
                        },
                        "bulletPreset": "NUMBERED_DECIMAL_NESTED",
                    }
                })
                # Apply indentation for nested lists
                if para.indent_level > 0:
                    formatting_requests.append({
                        "updateParagraphStyle": {
                            "range": {
                                "startIndex": index,
                                "endIndex": para_end,
                            },
                            "paragraphStyle": {
                                "indentFirstLine": {
                                    "magnitude": 18 * para.indent_level,
                                    "unit": "PT"
                                },
                                "indentStart": {
                                    "magnitude": 18 * para.indent_level,
                                    "unit": "PT"
                                }
                            },
                            "fields": "indentFirstLine,indentStart",
                        }
                    })

            # Apply formatting
            if formatting_requests:
                service.documents().batchUpdate(
                    documentId=doc_id,
                    body={"requests": formatting_requests},
                ).execute()

            return index + text_len

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to insert paragraph: {exc}") from exc

    def _insert_formatted_text_batch(
        self, doc_id: str, paragraphs: List[FormattedParagraph], index: int
    ) -> None:
        """
        Batch insert regular paragraphs (optimized path when no special elements).

        This is the original implementation for better performance.
        """
        service = self._get_service()

        # Build the full plain text first (with newlines)
        all_requests: List[Dict[str, Any]] = []
        formatting_requests: List[Dict[str, Any]] = []

        # Build plain text and track positions
        plain_text_parts: List[str] = []
        for para in paragraphs:
            para_text = "".join(seg.text for seg in para.segments)
            plain_text_parts.append(para_text)

        plain_text = "\n".join(plain_text_parts)

        # Insert all text first
        all_requests.append({
            "insertText": {
                "location": {"index": index},
                "text": plain_text,
            }
        })

        # Now build formatting requests
        # Track current position in the document
        current_pos = index
        for para_idx, para in enumerate(paragraphs):
            para_start = current_pos

            # Process each segment
            for seg in para.segments:
                seg_len = len(seg.text)
                if seg_len == 0:
                    continue

                seg.start_index = current_pos
                seg.end_index = current_pos + seg_len

                # Apply text styles (bold, italic, code, links)
                if seg.bold or seg.italic or seg.code or seg.link_url:
                    text_style: Dict[str, Any] = {}
                    fields = []

                    if seg.bold:
                        text_style["bold"] = True
                        fields.append("bold")
                    if seg.italic:
                        text_style["italic"] = True
                        fields.append("italic")
                    if seg.code:
                        # Use monospace font for inline code
                        text_style["weightedFontFamily"] = {
                            "fontFamily": "Consolas",
                            "weight": 400,
                        }
                        text_style["backgroundColor"] = {
                            "color": {"rgbColor": {"red": 0.93, "green": 0.93, "blue": 0.93}}
                        }
                        fields.extend(["weightedFontFamily", "backgroundColor"])
                    if seg.link_url:
                        text_style["link"] = {"url": seg.link_url}
                        fields.append("link")

                    formatting_requests.append({
                        "updateTextStyle": {
                            "range": {
                                "startIndex": seg.start_index,
                                "endIndex": seg.end_index,
                            },
                            "textStyle": text_style,
                            "fields": ",".join(fields),
                        }
                    })

                current_pos += seg_len

            # Calculate paragraph end (include the newline if not last paragraph)
            para_end = current_pos
            if para_idx < len(paragraphs) - 1:
                current_pos += 1  # Account for newline

            # Apply paragraph style (headers)
            if para.style != ParagraphStyle.NORMAL:
                formatting_requests.append({
                    "updateParagraphStyle": {
                        "range": {
                            "startIndex": para_start,
                            "endIndex": para_end,
                        },
                        "paragraphStyle": {
                            "namedStyleType": para.style.value,
                        },
                        "fields": "namedStyleType",
                    }
                })

            # Apply bullet list
            if para.is_bullet:
                formatting_requests.append({
                    "createParagraphBullets": {
                        "range": {
                            "startIndex": para_start,
                            "endIndex": para_end,
                        },
                        "bulletPreset": "BULLET_DISC_CIRCLE_SQUARE",
                    }
                })
                # Apply indentation for nested lists
                if para.indent_level > 0:
                    formatting_requests.append({
                        "updateParagraphStyle": {
                            "range": {
                                "startIndex": para_start,
                                "endIndex": para_end,
                            },
                            "paragraphStyle": {
                                "indentFirstLine": {
                                    "magnitude": 18 * para.indent_level,
                                    "unit": "PT"
                                },
                                "indentStart": {
                                    "magnitude": 18 * para.indent_level,
                                    "unit": "PT"
                                }
                            },
                            "fields": "indentFirstLine,indentStart",
                        }
                    })

            # Apply numbered list
            if para.is_numbered:
                formatting_requests.append({
                    "createParagraphBullets": {
                        "range": {
                            "startIndex": para_start,
                            "endIndex": para_end,
                        },
                        "bulletPreset": "NUMBERED_DECIMAL_NESTED",
                    }
                })
                # Apply indentation for nested lists
                if para.indent_level > 0:
                    formatting_requests.append({
                        "updateParagraphStyle": {
                            "range": {
                                "startIndex": para_start,
                                "endIndex": para_end,
                            },
                            "paragraphStyle": {
                                "indentFirstLine": {
                                    "magnitude": 18 * para.indent_level,
                                    "unit": "PT"
                                },
                                "indentStart": {
                                    "magnitude": 18 * para.indent_level,
                                    "unit": "PT"
                                }
                            },
                            "fields": "indentFirstLine,indentStart",
                        }
                    })

        # Execute all requests
        try:
            # First insert the text
            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": all_requests},
            ).execute()

            # Then apply formatting (if any)
            if formatting_requests:
                service.documents().batchUpdate(
                    documentId=doc_id,
                    body={"requests": formatting_requests},
                ).execute()

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to insert formatted text: {exc}") from exc

    def _insert_code_block(self, doc_id: str, code_block: CodeBlock, index: int) -> int:
        """Insert a code block with monospace font and gray background.

        Args:
            doc_id: Document ID
            code_block: CodeBlock to insert
            index: Document index to insert at

        Returns:
            New index position after insertion
        """
        service = self._get_service()

        # Add newline before and after for separation
        text = code_block.content + "\n"
        text_len = len(text)

        try:
            # Insert the text
            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": [{
                    "insertText": {
                        "location": {"index": index},
                        "text": text,
                    }
                }]},
            ).execute()

            # Apply code block formatting (Consolas + gray background)
            formatting_requests = [
                {
                    "updateTextStyle": {
                        "range": {
                            "startIndex": index,
                            "endIndex": index + text_len - 1,  # Exclude trailing newline
                        },
                        "textStyle": {
                            "weightedFontFamily": {
                                "fontFamily": "Consolas",
                                "weight": 400,
                            },
                            "backgroundColor": {
                                "color": {"rgbColor": {"red": 0.95, "green": 0.95, "blue": 0.95}}
                            }
                        },
                        "fields": "weightedFontFamily,backgroundColor",
                    }
                }
            ]

            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": formatting_requests},
            ).execute()

            return index + text_len

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to insert code block: {exc}") from exc

    def _get_table_cell_indices(
        self, doc: Dict[str, Any], table_start: int
    ) -> List[List[int]]:
        """Walk the document body to find cell content start indices.

        Tables appear as structural elements in doc['body']['content'].
        Each cell has a 'content' array with paragraph elements.
        Extract startIndex from each cell's first paragraph.

        Returns:
            [[row0_col0_idx, row0_col1_idx], [row1_col0_idx, ...], ...]
        """
        body = doc.get("body", {})
        content = body.get("content", [])

        cell_indices: List[List[int]] = []
        for element in content:
            if "table" in element:
                # Check if this table starts at or after our expected position
                elem_start = element.get("startIndex", 0)
                if elem_start >= table_start:
                    table_elem = element["table"]
                    rows = table_elem.get("tableRows", [])
                    logger.debug(
                        "Table found at index %d (expected >= %d), %d rows",
                        elem_start, table_start, len(rows),
                    )
                    for row in rows:
                        row_indices: List[int] = []
                        for cell in row.get("tableCells", []):
                            cell_content = cell.get("content", [])
                            if cell_content:
                                para = cell_content[0]
                                start_idx = para.get("startIndex")
                                if start_idx is not None:
                                    row_indices.append(start_idx)
                                else:
                                    logger.debug(
                                        "Cell paragraph missing startIndex, keys: %s",
                                        list(para.keys()),
                                    )
                        cell_indices.append(row_indices)
                    return cell_indices
        logger.debug(
            "No table element found with startIndex >= %d in %d content elements",
            table_start, len(content),
        )
        return cell_indices

    def _insert_table(self, doc_id: str, table: MarkdownTable, index: int) -> int:
        """Insert a markdown table as a Google Docs table.

        Optimized 4-call execution:
        1. InsertTableRequest (creates structure)
        2. Re-fetch document (discover cell indices + table end)
        3. InsertText for all cells (batched)
        4. Apply header formatting (batched)

        Args:
            doc_id: Document ID
            table: MarkdownTable to insert
            index: Document index to insert at

        Returns:
            New index position after insertion
        """
        if not table.rows:
            return index

        service = self._get_service()

        try:
            # API Call 1: Create table structure
            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": [{
                    "insertTable": {
                        "rows": len(table.rows),
                        "columns": table.column_count,
                        "location": {"index": index}
                    }
                }]},
            ).execute()

            # API Call 2: Re-fetch to discover cell indices and table position
            doc = service.documents().get(documentId=doc_id).execute()
            cell_indices = self._get_table_cell_indices(doc, index)

            # Also find table start/end from this same fetch
            table_start_idx = None
            table_end_idx = index
            body = doc.get("body", {})
            content = body.get("content", [])
            for element in content:
                if "table" in element:
                    elem_start = element.get("startIndex", 0)
                    if elem_start >= index:
                        table_start_idx = elem_start
                        table_end_idx = element.get("endIndex", index)
                        break

            if not cell_indices:
                logger.warning("Could not find table cell indices")
                # Estimate: each cell is ~2 chars (content + newline), plus table overhead
                return index + len(table.rows) * table.column_count * 2 + 4

            # Calculate total text being inserted for end position adjustment
            total_text_len = sum(
                len(cell.content)
                for row in table.rows
                for cell in row.cells
                if cell.content
            )

            # API Call 3: Populate all cells (in reverse order to preserve indices)
            text_requests: List[Dict[str, Any]] = []
            formatting_requests: List[Dict[str, Any]] = []
            for row_idx in range(len(table.rows) - 1, -1, -1):
                row = table.rows[row_idx]
                for col_idx in range(len(row.cells) - 1, -1, -1):
                    if row_idx >= len(cell_indices) or col_idx >= len(cell_indices[row_idx]):
                        continue

                    cell = row.cells[col_idx]
                    cell_index = cell_indices[row_idx][col_idx]

                    if cell.content:
                        text_requests.append({
                            "insertText": {
                                "location": {"index": cell_index},
                                "text": cell.content
                            }
                        })

                    segment_offset = 0
                    for segment in cell.segments:
                        seg_len = len(segment.text)
                        if seg_len == 0:
                            continue

                        seg_start = cell_index + segment_offset
                        seg_end = seg_start + seg_len
                        segment_offset += seg_len

                        if (
                            segment.bold
                            or segment.italic
                            or segment.code
                            or segment.link_url
                        ):
                            text_style: Dict[str, Any] = {}
                            fields: List[str] = []
                            if segment.bold:
                                text_style["bold"] = True
                                fields.append("bold")
                            if segment.italic:
                                text_style["italic"] = True
                                fields.append("italic")
                            if segment.code:
                                text_style["weightedFontFamily"] = {
                                    "fontFamily": "Consolas",
                                    "weight": 400,
                                }
                                text_style["backgroundColor"] = {
                                    "color": {"rgbColor": {"red": 0.93, "green": 0.93, "blue": 0.93}}
                                }
                                fields.extend(["weightedFontFamily", "backgroundColor"])
                            if segment.link_url:
                                text_style["link"] = {"url": segment.link_url}
                                fields.append("link")

                            formatting_requests.append({
                                "updateTextStyle": {
                                    "range": {"startIndex": seg_start, "endIndex": seg_end},
                                    "textStyle": text_style,
                                    "fields": ",".join(fields),
                                }
                            })

                    if (
                        table.column_alignments
                        and col_idx < len(table.column_alignments)
                        and cell.content
                    ):
                        alignment_name = table.column_alignments[col_idx]
                        alignment_map = {
                            "left": "START",
                            "right": "END",
                            "center": "CENTER",
                        }
                        docs_alignment = alignment_map.get(alignment_name, "START")
                        if docs_alignment != "START":
                            formatting_requests.append({
                                "updateParagraphStyle": {
                                    "range": {
                                        "startIndex": cell_index,
                                        "endIndex": cell_index + len(cell.content),
                                    },
                                    "paragraphStyle": {"alignment": docs_alignment},
                                    "fields": "alignment",
                                }
                            })

            if text_requests:
                service.documents().batchUpdate(
                    documentId=doc_id,
                    body={"requests": text_requests},
                ).execute()

            # API Call 4: Apply cell formatting + header formatting.
            if table_start_idx is not None:
                # Header cell background (light blue #D0E4F5)
                for col_idx in range(table.column_count):
                    formatting_requests.append({
                        "updateTableCellStyle": {
                            "tableCellStyle": {
                                "backgroundColor": {
                                    "color": {"rgbColor": {
                                        "red": 0.816,
                                        "green": 0.894,
                                        "blue": 0.961
                                    }}
                                }
                            },
                            "tableRange": {
                                "tableCellLocation": {
                                    "tableStartLocation": {"index": table_start_idx},
                                    "rowIndex": 0,
                                    "columnIndex": col_idx
                                },
                                "rowSpan": 1,
                                "columnSpan": 1
                            },
                            "fields": "backgroundColor"
                        }
                    })

            if formatting_requests:
                try:
                    service.documents().batchUpdate(
                        documentId=doc_id,
                        body={"requests": formatting_requests},
                    ).execute()
                except HttpError:
                    logger.warning("Could not apply table cell formatting")

            # Return estimated end position (table_end + text inserted)
            return table_end_idx + total_text_len

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to insert table: {exc}") from exc

    def _insert_placeholder(self, doc_id: str, description: str, index: int) -> int:
        """Insert a screenshot placeholder with styled callout box.

        Args:
            doc_id: Document ID
            description: Placeholder description
            index: Document index to insert at

        Returns:
            New index position after insertion
        """
        service = self._get_service()
        placeholder_text = f"[Screenshot: {description}]\n"
        text_len = len(placeholder_text)

        try:
            # Insert text
            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": [{
                    "insertText": {
                        "location": {"index": index},
                        "text": placeholder_text,
                    }
                }]},
            ).execute()

            # Apply styling - yellow callout box with gold left border
            # Note: Explicit padding required to avoid UNIT_UNSPECIFIED error
            formatting_requests = [
                {
                    "updateParagraphStyle": {
                        "range": {
                            "startIndex": index,
                            "endIndex": index + text_len,
                        },
                        "paragraphStyle": {
                            "borderLeft": {
                                "color": {"color": {"rgbColor": {
                                    "red": 0.9, "green": 0.8, "blue": 0.4
                                }}},
                                "width": {"magnitude": 3, "unit": "PT"},
                                "dashStyle": "SOLID",
                                "padding": {"magnitude": 6, "unit": "PT"}
                            },
                            "borderTop": {
                                "color": {"color": {"rgbColor": {
                                    "red": 0.7, "green": 0.7, "blue": 0.7
                                }}},
                                "width": {"magnitude": 1, "unit": "PT"},
                                "dashStyle": "SOLID",
                                "padding": {"magnitude": 6, "unit": "PT"}
                            },
                            "borderBottom": {
                                "color": {"color": {"rgbColor": {
                                    "red": 0.7, "green": 0.7, "blue": 0.7
                                }}},
                                "width": {"magnitude": 1, "unit": "PT"},
                                "dashStyle": "SOLID",
                                "padding": {"magnitude": 6, "unit": "PT"}
                            },
                            "shading": {
                                "backgroundColor": {"color": {"rgbColor": {
                                    "red": 1.0, "green": 0.98, "blue": 0.9
                                }}}
                            }
                        },
                        "fields": "borderLeft,borderTop,borderBottom,shading"
                    }
                },
                {
                    "updateTextStyle": {
                        "range": {
                            "startIndex": index,
                            "endIndex": index + text_len - 1,  # Exclude trailing newline
                        },
                        "textStyle": {"italic": True},
                        "fields": "italic"
                    }
                }
            ]

            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": formatting_requests},
            ).execute()

            return index + text_len

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to insert placeholder: {exc}") from exc

    @staticmethod
    def _is_markdown(content: str) -> bool:
        """Detect if content appears to be markdown."""
        return MarkdownParser.looks_like_markdown(content)

    def _append_text(self, doc_id: str, text: str) -> None:
        """Append text to end of document."""
        service = self._get_service()

        # Get document to find end index
        try:
            doc = service.documents().get(documentId=doc_id).execute()
            body = doc.get("body", {})
            content = body.get("content", [])

            # Find the end index
            end_index = 1
            if content:
                last_element = content[-1]
                end_index = last_element.get("endIndex", 1) - 1

            requests = [
                {
                    "insertText": {
                        "location": {"index": end_index},
                        "text": text,
                    }
                }
            ]

            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": requests},
            ).execute()

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to append text: {exc}") from exc

    def _append_formatted_text(
        self,
        doc_id: str,
        content: str,
        parse_result: Optional[MarkdownParseResult] = None,
    ) -> None:
        """Append formatted text to end of document."""
        service = self._get_service()

        try:
            doc = service.documents().get(documentId=doc_id).execute()
            body = doc.get("body", {})
            doc_content = body.get("content", [])

            # Find the end index
            end_index = 1
            if doc_content:
                last_element = doc_content[-1]
                end_index = last_element.get("endIndex", 1) - 1

            # Use formatted insert at end position
            self._insert_formatted_text(
                doc_id,
                content,
                end_index,
                parse_result=parse_result,
            )

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to append formatted text: {exc}") from exc

    def _clear_content(self, doc_id: str) -> None:
        """Clear all content from document."""
        service = self._get_service()

        try:
            doc = service.documents().get(documentId=doc_id).execute()
            body = doc.get("body", {})
            content = body.get("content", [])

            if not content or len(content) <= 1:
                return

            # Find content range (skip structural elements)
            start_index = 1
            end_index = 1

            for element in content:
                end_idx = element.get("endIndex", 1)
                if end_idx > end_index:
                    end_index = end_idx

            # Account for trailing newline
            if end_index > 1:
                end_index -= 1

            if end_index <= start_index:
                return

            requests = [
                {
                    "deleteContentRange": {
                        "range": {
                            "startIndex": start_index,
                            "endIndex": end_index,
                        }
                    }
                }
            ]

            service.documents().batchUpdate(
                documentId=doc_id,
                body={"requests": requests},
            ).execute()

        except HttpError as exc:
            raise GoogleDocsError(f"Failed to clear content: {exc}") from exc

    def _extract_text_content(self, doc: Dict[str, Any]) -> str:
        """Extract plain text content from document structure.

        Walks both paragraph and table structural elements so that table
        cell content is included as pipe-delimited rows instead of being
        silently omitted.
        """
        body = doc.get("body", {})
        content = body.get("content", [])
        text_parts: List[str] = []

        for element in content:
            if "paragraph" in element:
                paragraph = element["paragraph"]
                for text_run in paragraph.get("elements", []):
                    if "textRun" in text_run:
                        text_parts.append(text_run["textRun"].get("content", ""))
            elif "table" in element:
                table = element["table"]
                for row in table.get("tableRows", []):
                    row_texts: List[str] = []
                    for cell in row.get("tableCells", []):
                        cell_text: List[str] = []
                        for para in cell.get("content", []):
                            if "paragraph" in para:
                                for el in para["paragraph"].get("elements", []):
                                    if "textRun" in el:
                                        cell_text.append(
                                            el["textRun"].get("content", "").rstrip("\n")
                                        )
                        row_texts.append(" ".join(cell_text).strip())
                    text_parts.append(" | ".join(row_texts) + "\n")

        return "".join(text_parts)

    def _extract_doc_id(self, doc_id_or_url: str) -> str:
        """Extract document ID from URL or return ID as-is."""
        # Handle full URL
        if "docs.google.com" in doc_id_or_url:
            # Extract ID from URL like:
            # https://docs.google.com/document/d/DOC_ID/edit
            match = re.search(r"/document/d/([a-zA-Z0-9_-]+)", doc_id_or_url)
            if match:
                return match.group(1)
            raise GoogleDocsError(f"Could not extract document ID from URL: {doc_id_or_url}")

        return doc_id_or_url


def get_docs_service() -> Resource:
    """
    Build and return the Google Docs API service client.

    Uses the same credentials as Sheets/Drive from google_client.py.
    """
    credentials = load_credentials()
    return build("docs", "v1", credentials=credentials, cache_discovery=False)


# Convenience functions
def create_doc(
    title: str,
    content: Optional[str] = None,
    content_format: str = "auto",
    markdown_mode: str = "extended",
) -> GoogleDoc:
    """Create a new Google Doc."""
    client = GoogleDocsClient()
    return client.create_document(
        title,
        content,
        content_format=content_format,
        markdown_mode=markdown_mode,
    )


def read_doc(doc_id: str) -> GoogleDoc:
    """Read a Google Doc by ID or URL."""
    client = GoogleDocsClient()
    return client.get_document(doc_id)


def update_doc(
    doc_id: str,
    content: str,
    replace: bool = True,
    content_format: str = "auto",
    markdown_mode: str = "extended",
) -> GoogleDoc:
    """Update a Google Doc's content."""
    client = GoogleDocsClient()
    return client.update_document(
        doc_id,
        content,
        replace,
        content_format=content_format,
        markdown_mode=markdown_mode,
    )


def append_to_doc(
    doc_id: str,
    content: str,
    content_format: str = "auto",
    markdown_mode: str = "extended",
) -> GoogleDoc:
    """Append content to a Google Doc."""
    client = GoogleDocsClient()
    return client.append_content(
        doc_id,
        content,
        content_format=content_format,
        markdown_mode=markdown_mode,
    )


def preview_markdown(
    content: str,
    content_format: str = "auto",
    markdown_mode: str = "extended",
) -> MarkdownParseReport:
    """Build markdown parse diagnostics without mutating remote Docs."""
    client = GoogleDocsClient()
    return client.build_markdown_report(
        content=content,
        content_format=content_format,
        markdown_mode=markdown_mode,
    )


__all__ = [
    # Data classes
    "GoogleDoc",
    "TableCell",
    "TableRow",
    "MarkdownTable",
    "CodeBlock",
    "ScreenshotPlaceholder",
    "FormattedParagraph",
    "TextSegment",
    # Client and errors
    "GoogleDocsError",
    "GoogleDocsClient",
    "MarkdownParser",
    "MarkdownParseWarning",
    "MarkdownParseReport",
    "MarkdownParseResult",
    "MarkdownValidationError",
    # Convenience functions
    "get_docs_service",
    "create_doc",
    "read_doc",
    "update_doc",
    "append_to_doc",
    "preview_markdown",
    "DOCS_SCOPE",
]

