"""LinkedIn-specific CRDT document parser.

This module provides parsing for CRDT documents containing LinkedIn
post data with linkedinpost and linkedincomment nodes.

Intentionally skipped frontend-only attributes:
    - ``og`` on linkedinpost: frontend-only link card preview
    - ``pdf_images`` on linkedinpost: frontend-only PDF page thumbnails
    - ``hidden`` on linkedincomment: frontend-only UI visibility toggle
    - Link marks (linkedinLink): stripped during text extraction, raw text preserved
"""

from __future__ import annotations

import logging
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from pycrdt import XmlElement, XmlFragment, XmlText

from marqetive.utils.crdt.exceptions import CRDTEmptyDocumentError, CRDTParseError
from marqetive.utils.crdt.models import (
    LinkedInCommentContent,
    LinkedInPostContent,
    ParsedLinkedInDocument,
    PostVisibility,
)
from marqetive.utils.crdt.platforms.base import BaseCRDTParser

if TYPE_CHECKING:
    from pycrdt import Doc

logger = logging.getLogger(__name__)

_LINKEDIN_URN_RE = re.compile(r"^urn:li:(organization|person):[A-Za-z0-9_-]+$")


class LinkedInCRDTParser(BaseCRDTParser[ParsedLinkedInDocument]):
    """Parser for LinkedIn CRDT documents.

    Handles parsing of linkedinpost and linkedincomment nodes.
    Supports media attachments, PDF documents, and visibility settings.

    Validation rules:
        - Max 3000 characters for post
        - Max 1250 characters for comment
        - Multiple media allowed
        - PDF attachments supported
        - Visibility settings: PUBLIC, CONNECTIONS, LOGGED_IN
    """

    @property
    def platform_name(self) -> str:
        """Return the platform name."""
        return "linkedin"

    @property
    def root_node_type(self) -> str:
        """Return the root node type for LinkedIn."""
        return "linkedinpost"

    def parse_document(
        self,
        doc: Doc[Any],
        fragment_name: str = "default",
        *,
        include_auto_number: bool = False,  # noqa: ARG002
    ) -> ParsedLinkedInDocument:
        """Parse a LinkedIn CRDT document.

        Args:
            doc: The pycrdt Doc object to parse
            fragment_name: Name of the XML fragment to parse

        Returns:
            ParsedLinkedInDocument with parsed post and optional comment

        Raises:
            CRDTEmptyDocumentError: If no post found
            CRDTParseError: If parsing fails
        """
        fragment = self._get_fragment(doc, fragment_name)
        raw_attributes = self._get_node_attributes(fragment)

        # Find the linkedinpost node
        post_nodes = self._find_nodes_by_type(fragment, self.root_node_type)

        if not post_nodes:
            raise CRDTEmptyDocumentError(
                "No LinkedIn post found in document",
                fragment_name=fragment_name,
            )

        # Parse the main post
        try:
            post = self._parse_post(post_nodes[0])
        except Exception as e:
            raise CRDTParseError(
                f"Failed to parse LinkedIn post: {e}",
                platform=self.platform_name,
                node_type=self.root_node_type,
            ) from e

        # Find and parse optional comment
        comment: LinkedInCommentContent | None = None
        comment_nodes = self._find_nodes_by_type(fragment, "linkedincomment")
        if comment_nodes:
            try:
                comment = self._parse_comment(comment_nodes[0])
            except Exception as e:
                raise CRDTParseError(
                    f"Failed to parse LinkedIn comment: {e}",
                    platform=self.platform_name,
                    node_type="linkedincomment",
                ) from e

        return ParsedLinkedInDocument(
            post=post,
            comment=comment,
            raw_attributes=raw_attributes,
            parsed_at=datetime.now(UTC),
        )

    def _parse_post(self, node: XmlElement) -> LinkedInPostContent:
        """Parse a linkedinpost node.

        Matches production linkedin_parser.py:73-88 behavior.

        Args:
            node: The linkedinpost XML node

        Returns:
            LinkedInPostContent with parsed data
        """
        attrs = self._get_node_attributes(node)

        # Read post ID from attrs
        post_id = attrs.get("id")
        if post_id is not None:
            post_id = str(post_id)

        # Extract text content
        text = self._extract_linkedin_text(node)

        # Parse media via space-separated URL strings
        media = self._parse_media_urls(node, "media")

        # Read PDF as simple string
        pdf = attrs.get("pdf")
        if pdf is not None:
            pdf = str(pdf)

        # Parse visibility from "postVisibility" attr (production uses this name)
        visibility_str = attrs.get("postVisibility", "PUBLIC")
        visibility = self._parse_visibility(str(visibility_str))

        # Read og (link preview) as simple string
        og = attrs.get("og")
        if og is not None:
            og = str(og)

        # Read pdf_images as simple string
        pdf_images = attrs.get("pdf_images")
        if pdf_images is not None:
            pdf_images = str(pdf_images)

        return LinkedInPostContent(
            id=post_id,
            text=text,
            media=media,
            pdf=pdf,
            visibility=visibility,
            og=og,
            pdf_images=pdf_images,
        )

    def _parse_comment(self, node: XmlElement) -> LinkedInCommentContent:
        """Parse a linkedincomment node.

        Args:
            node: The linkedincomment XML node

        Returns:
            LinkedInCommentContent with parsed data
        """
        attrs = self._get_node_attributes(node)
        comment_id = attrs.get("id")
        if comment_id is not None:
            comment_id = str(comment_id)
        text = self._extract_linkedin_text(node)

        # Parse comment media
        media = self._parse_media_urls(node, "media")

        # Parse hidden flag (defaults to False when attr is absent)
        hidden_val = attrs.get("hidden")
        hidden = False
        if hidden_val is not None:
            if isinstance(hidden_val, bool):
                hidden = hidden_val
            else:
                hidden = str(hidden_val).strip().lower() != "false"

        return LinkedInCommentContent(
            id=comment_id, text=text, media=media, hidden=hidden
        )

    def _parse_visibility(self, visibility_str: str) -> PostVisibility:
        """Parse visibility string to enum.

        Handles uppercase values matching schema/production defaults.

        Args:
            visibility_str: Visibility setting string

        Returns:
            PostVisibility enum value
        """
        visibility_map = {
            "PUBLIC": PostVisibility.PUBLIC,
            "CONNECTIONS": PostVisibility.CONNECTIONS,
            "LOGGED_IN": PostVisibility.LOGGED_IN,
        }

        return visibility_map.get(visibility_str.strip(), PostVisibility.PUBLIC)

    def _extract_linkedin_text(self, node: XmlElement) -> str:
        """Extract text content while preserving LinkedIn mention syntax."""
        paragraphs: list[str] = []

        try:
            for child in node.children:
                if (
                    isinstance(child, XmlElement)
                    and self._get_node_type(child) == "paragraph"
                ):
                    paragraphs.append(self._extract_linkedin_paragraph_text(child))
        except (AttributeError, TypeError):
            logger.debug(
                "Failed to iterate children of node %r during LinkedIn text extraction",
                node,
                exc_info=True,
            )

        if paragraphs:
            return "\n".join(paragraphs)

        parts: list[str] = []
        self._collect_linkedin_text_recursive(node, parts)
        return "".join(parts)

    def _extract_linkedin_paragraph_text(self, node: XmlElement) -> str:
        """Extract a paragraph, serializing LinkedIn mentions to commentary text."""
        parts: list[str] = []

        try:
            for child in node.children:
                if isinstance(child, str):
                    parts.append(child)
                elif isinstance(child, XmlText):
                    parts.append(self._xml_text_linkedin(child))
                elif isinstance(child, XmlElement):
                    if self._get_node_type(child) == "hard_break":
                        parts.append("\n")
                    else:
                        self._collect_linkedin_text_recursive(child, parts)
        except (AttributeError, TypeError):
            logger.debug(
                "Failed to iterate children of node %r in LinkedIn paragraph text extraction",
                node,
                exc_info=True,
            )

        return "".join(parts)

    def _collect_linkedin_text_recursive(
        self, node: XmlElement | XmlFragment | XmlText | str, parts: list[str]
    ) -> None:
        """Recursively collect text while preserving LinkedIn mentions."""
        if isinstance(node, str):
            parts.append(node)
            return

        if isinstance(node, XmlText):
            parts.append(self._xml_text_linkedin(node))
            return

        if not isinstance(node, (XmlElement, XmlFragment)):
            return

        mention_urn = self._extract_linkedin_mention_urn(
            self._get_node_attributes(node)
        )
        if mention_urn:
            label = self._extract_text(node)
            parts.append(self._format_linkedin_mention(label, mention_urn))
            return

        try:
            for child in node.children:
                self._collect_linkedin_text_recursive(child, parts)
        except (AttributeError, TypeError):
            logger.debug(
                "Failed to iterate children of node %r in LinkedIn recursive text collection",
                node,
                exc_info=True,
            )

    def _xml_text_linkedin(self, node: XmlText) -> str:
        """Serialize XmlText segments, preserving mention formatting."""
        serialized: list[str] = []

        for segment, attrs in node.diff():
            text = str(segment)
            mention_urn = self._extract_linkedin_mention_urn(attrs)
            if mention_urn:
                serialized.append(self._format_linkedin_mention(text, mention_urn))
            else:
                serialized.append(text)

        return "".join(serialized)

    def _extract_linkedin_mention_urn(self, attrs: Any) -> str | None:
        """Find a LinkedIn entity URN inside mark or node attributes."""
        if not isinstance(attrs, dict):
            return None

        direct_keys = (
            "id",
            "urn",
            "entityUrn",
            "entityURN",
            "mentionUrn",
            "organizationUrn",
            "personUrn",
        )
        for key in direct_keys:
            value = attrs.get(key)
            urn = self._coerce_linkedin_urn(value)
            if urn:
                return urn

        for key, value in attrs.items():
            key_lower = str(key).lower()
            if key_lower.endswith("urn") or key_lower.endswith("mention"):
                urn = self._coerce_linkedin_urn(value)
                if urn:
                    return urn
            if isinstance(value, dict):
                urn = self._extract_linkedin_mention_urn(value)
                if urn:
                    return urn

        return None

    def _coerce_linkedin_urn(self, value: Any) -> str | None:
        """Normalize values that may contain a LinkedIn person/org URN."""
        if isinstance(value, str):
            stripped = value.strip()
            if _LINKEDIN_URN_RE.fullmatch(stripped):
                return stripped
            return None

        if isinstance(value, dict):
            for nested in value.values():
                urn = self._coerce_linkedin_urn(nested)
                if urn:
                    return urn

        return None

    def _format_linkedin_mention(self, label: str, urn: str) -> str:
        """Serialize a mention segment into LinkedIn Posts API syntax."""
        clean_label = label.strip()
        if clean_label.startswith("@"):
            clean_label = clean_label[1:]
        return f"@[{clean_label}]({urn})" if clean_label else ""
