"""
String manipulation functions for templates.

Provides essential string functions for text processing in templates.

Many of these functions are now thin wrappers around bengal.utils.text utilities
to avoid code duplication and ensure consistency.
"""

from __future__ import annotations

import base64
import re
from typing import TYPE_CHECKING, Any

from bengal.config.utils import coerce_int
from bengal.errors import ErrorCode
from bengal.utils.observability.logger import get_logger
from bengal.utils.primitives import text as text_utils

logger = get_logger(__name__)

if TYPE_CHECKING:
    from bengal.protocols import SiteLike, TemplateEnvironment


def register(env: TemplateEnvironment, site: SiteLike) -> None:
    """Register string functions with template environment."""
    env.filters.update(
        {
            "truncatewords": truncatewords,
            "truncatewords_html": truncatewords_html,
            "slugify": slugify,
            "markdownify": markdownify,
            "strip_html": strip_html,
            "plainify": strip_html,  # Hugo compatibility alias
            "truncate_chars": truncate_chars,
            "replace_regex": replace_regex,
            "pluralize": pluralize,
            "reading_time": reading_time,
            "word_count": word_count,
            "wordcount": word_count,  # Alias matching Jinja naming convention
            "excerpt": excerpt,
            "excerpt_for_card": excerpt_for_card,
            "card_excerpt": card_excerpt,
            "card_excerpt_html": card_excerpt_html,
            "strip_whitespace": strip_whitespace,
            "get": dict_get,
            "first_sentence": first_sentence,
            "filesize": filesize,
            "split": split_string,
            "base64_encode": base64_encode,
            "base64_decode": base64_decode,
        }
    )


def split_string(text: str, separator: str = " ") -> list[str]:
    """
    Split a string into a list using a separator.

    Args:
        text: Text to split
        separator: Separator string (default: space)

    Returns:
        List of string parts

    Example:
        {{ "1.2.3" | split('.') }}  # ["1", "2", "3"]
        {{ "a,b,c" | split(',') }}  # ["a", "b", "c"]

    """
    if not text:
        return []
    return text.split(separator)


def dict_get(obj: Any, key: str, default: Any = None) -> Any:
    """Safe get supporting dict-like objects for component preview contexts.

    This is a thin wrapper around bengal.rendering.utils.safe_access.safe_get
    for template filter use.
    """
    from bengal.rendering.utils.safe_access import safe_get

    return safe_get(obj, key, default)


def truncatewords(text: str, count: int, suffix: str = "...") -> str:
    """
    Truncate text to a specified number of words.

    Uses bengal.utils.text.truncate_words internally.

    Args:
        text: Text to truncate
        count: Maximum number of words
        suffix: Text to append when truncated (default: "...")

    Returns:
        Truncated text with suffix if needed

    Example:
        {{ post.content | truncatewords(50) }}
        {{ post.content | truncatewords(30, " [Read more]") }}

    """
    count = coerce_int(count, 30)
    return text_utils.truncate_words(text, count, suffix)


def truncatewords_html(html: str, count: int, suffix: str = "...") -> str:
    """
    Truncate HTML text to word count, preserving HTML structure.

    Uses a tag-aware approach that:
    1. Counts only text content words (not tag content)
    2. Keeps track of open tags
    3. Closes any unclosed tags at truncation point

    Args:
        html: HTML text to truncate
        count: Maximum number of words
        suffix: Text to append when truncated

    Returns:
        Truncated HTML with properly closed tags

    Example:
        {{ post.html_content | truncatewords_html(50) }}
        {{ "<p>Hello <strong>world</strong></p>" | truncatewords_html(1) }}  # "<p>Hello...</p>"

    """
    if not html:
        return ""
    count = coerce_int(count, 30)

    # Quick check - if plain text word count is under limit, return as-is
    text_only = strip_html(html)
    if len(text_only.split()) <= count:
        return html

    # HTML5 void elements (no closing tag needed)
    void_elements = frozenset(
        {
            "area",
            "base",
            "br",
            "col",
            "embed",
            "hr",
            "img",
            "input",
            "link",
            "meta",
            "source",
            "track",
            "wbr",
        }
    )

    # Tag-aware truncation
    result: list[str] = []
    word_count = 0
    open_tags: list[str] = []
    i = 0

    while i < len(html) and word_count < count:
        if html[i] == "<":
            # Find end of tag
            tag_end = html.find(">", i)
            if tag_end == -1:
                break
            tag = html[i : tag_end + 1]
            result.append(tag)

            # Track open/close tags
            if tag.startswith("</"):
                # Closing tag
                tag_name = tag[2:-1].split()[0].lower() if len(tag) > 3 else ""
                if open_tags and open_tags[-1] == tag_name:
                    open_tags.pop()
            elif not tag.endswith("/>") and not tag.startswith("<!"):
                # Opening tag (not self-closing, not comment/doctype)
                tag_content = tag[1:-1]
                tag_name = tag_content.split()[0].lower() if tag_content else ""
                if tag_name and tag_name not in void_elements:
                    open_tags.append(tag_name)

            i = tag_end + 1
        else:
            # Find next tag or end
            next_tag = html.find("<", i)
            text = html[i:] if next_tag == -1 else html[i:next_tag]

            # Count and truncate words in this text segment
            words = text.split()
            remaining = count - word_count

            if len(words) <= remaining:
                result.append(text)
                word_count += len(words)
                i = next_tag if next_tag != -1 else len(html)
            else:
                # Truncate within this segment
                result.append(" ".join(words[:remaining]))
                word_count = count
                break

    # Add suffix
    result.append(suffix)

    # Close any unclosed tags (in reverse order)
    result.extend(f"</{tag}>" for tag in reversed(open_tags))

    return "".join(result)


def slugify(text: str) -> str:
    """
    Convert text to URL-safe slug.

    Uses bengal.utils.text.slugify internally.
    Converts to lowercase, removes special characters, replaces spaces with hyphens.

    Args:
        text: Text to convert

    Returns:
        URL-safe slug

    Example:
        {{ page.title | slugify }}  # "Hello World!" -> "hello-world"

    """
    return text_utils.slugify(text, unescape_html=False)


def _convert_docstring_to_markdown(text: str) -> str:
    """
    Convert Google/NumPy-style docstrings to markdown.

    Handles:
    - Indented lists (    - Item) → proper markdown lists
    - Section headers (Section:) → bold labels or headings
    - Doctest blocks (>>> ...) → fenced code blocks
    - Preserves existing code blocks

    Args:
        text: Docstring text

    Returns:
        Markdown-formatted text

    """
    if not text:
        return ""

    # First pass: convert non-indented doctest blocks to fenced code blocks
    # This prevents >>> from being interpreted as blockquotes by mistune
    text = _convert_doctest_to_codeblocks(text)

    lines = text.split("\n")
    result = []
    in_code_block = False

    for line in lines:
        # Track code blocks - don't modify inside them
        if line.strip().startswith("```"):
            in_code_block = not in_code_block
            result.append(line)
            continue

        if in_code_block:
            result.append(line)
            continue

        # Convert indented list items to proper markdown lists
        # "    - Item: Description" → "- **Item**: Description"
        # Match: 4+ spaces, dash, text
        match = re.match(r"^(\s{4,})- (.+)$", line)
        if match:
            content = match.group(2)
            # Check if it's "Term: Description" format
            term_match = re.match(r"^([^:]+):\s*(.*)$", content)
            if term_match:
                term, desc = term_match.groups()
                # Don't double-wrap already bolded terms (e.g., "**Rendering**")
                if term.startswith("**") and term.endswith("**"):
                    result.append(f"- {term}: {desc}")
                else:
                    result.append(f"- **{term}**: {desc}")
            else:
                result.append(f"- {content}")
            continue

        # Convert section headers: "Section:" at start of line → "**Section:**"
        section_match = re.match(r"^([A-Z][A-Za-z\s]+):$", line.strip())
        if section_match and not line.startswith(" "):
            section_name = section_match.group(1)
            result.append(f"\n**{section_name}:**\n")
            continue

        result.append(line)

    return "\n".join(result)


def _convert_doctest_to_codeblocks(text: str) -> str:
    """
    Convert doctest-style examples (>>> ...) to fenced code blocks.

    This prevents >>> from being interpreted as nested blockquotes by markdown.
    Only converts non-indented doctest blocks (indented ones become code blocks
    automatically via 4-space markdown rule).

    Args:
        text: Text containing potential doctest blocks

    Returns:
            Text with doctest blocks wrapped in ```python fences

    """
    lines = text.split("\n")
    result = []
    doctest_buffer: list[str] = []
    in_fenced_block = False

    def flush_doctest():
        """Flush accumulated doctest lines as a fenced code block."""
        if doctest_buffer:
            result.append("```python")
            result.extend(doctest_buffer)
            result.append("```")
            doctest_buffer.clear()

    for line in lines:
        stripped = line.strip()

        # Track existing fenced code blocks - don't modify inside them
        if stripped.startswith("```"):
            flush_doctest()
            in_fenced_block = not in_fenced_block
            result.append(line)
            continue

        if in_fenced_block:
            result.append(line)
            continue

        # Skip already-indented lines (4+ spaces) - they'll become code blocks naturally
        if line.startswith(("    ", "\t")):
            flush_doctest()
            result.append(line)
            continue

        # Check for doctest line (>>> at start, with optional minimal whitespace)
        # Match: optional 0-3 spaces, then >>>
        if re.match(r"^[ ]{0,3}>>>", line):
            doctest_buffer.append(line)
            continue

        # Check for doctest continuation (output line or ... continuation)
        # Only if we're already in a doctest block
        if doctest_buffer:
            # Continuation line: starts with ... or is doctest output (no prefix)
            # Output lines are typically not starting with >>> or ...
            if stripped.startswith("...") or (
                stripped and not stripped.startswith(">>>") and not stripped.endswith(":")
            ):
                doctest_buffer.append(line)
                continue
            else:
                # End of doctest block
                flush_doctest()

        result.append(line)

    # Flush any remaining doctest
    flush_doctest()

    return "\n".join(result)


def markdownify(text: str) -> str:
    """
    Render Markdown text to HTML.

    Pre-processes Google-style docstrings to markdown, then converts to HTML
    using Patitas (Bengal's native parser) with table and strikethrough support.

    Args:
        text: Markdown or docstring text

    Returns:
        Rendered HTML

    Example:
        {{ markdown_text | markdownify | safe }}

    """
    if not text:
        return ""

    # Pre-process docstring-style text to markdown
    text = _convert_docstring_to_markdown(text)

    # Use Patitas parser (Bengal's default parser)
    # with table and strikethrough plugins enabled (matching previous mistune behavior)
    from bengal.parsing.backends.patitas import create_markdown

    md = create_markdown(plugins=["table", "strikethrough"])
    return md(text)


def strip_html(text: str) -> str:
    """
    Remove all HTML tags from text.

    Uses bengal.utils.text.strip_html internally.

    Args:
        text: HTML text

    Returns:
        Text with HTML tags removed

    Example:
        {{ post.html_content | strip_html }}

    """
    return text_utils.strip_html(text, decode_entities=True)


def truncate_chars(text: str, length: int, suffix: str = "...") -> str:
    """
    Truncate text to character length.

    Uses bengal.utils.text.truncate_chars internally.

    Args:
        text: Text to truncate
        length: Maximum character length
        suffix: Text to append when truncated

    Returns:
        Truncated text with suffix if needed

    Example:
        {{ post.excerpt | truncate_chars(200) }}

    """
    length = coerce_int(length, 120)
    return text_utils.truncate_chars(text, length, suffix)


def replace_regex(text: str, pattern: str, replacement: str) -> str:
    """
    Replace text using regular expression.

    Args:
        text: Text to search in
        pattern: Regular expression pattern
        replacement: Replacement text

    Returns:
        Text with replacements made

    Example:
        {{ text | replace_regex('\\d+', 'NUM') }}

    """
    if not text:
        return ""

    try:
        return re.sub(pattern, replacement, text)
    except re.error as e:
        # Log warning for invalid regex (developer error)
        logger.warning(
            "replace_regex_invalid_pattern",
            pattern=pattern,
            error=str(e),
            caller="template",
            error_code=ErrorCode.R003.value,
            suggestion="Check regex syntax; escape special characters",
        )
        return text


def pluralize(count: int, singular: str, plural: str | None = None) -> str:
    """
    Return singular or plural form based on count.

    Uses bengal.utils.text.pluralize internally.

    Args:
        count: Number to check
        singular: Singular form
        plural: Plural form (default: singular + 's')

    Returns:
        Appropriate form based on count

    Example:
        {{ posts | length }} {{ posts | length | pluralize('post', 'posts') }}
        {{ count | pluralize('item') }}  # auto-pluralizes to "items"

    """
    return text_utils.pluralize(count, singular, plural)


def reading_time(text: str, wpm: int = 200) -> int:
    """
    Calculate reading time in minutes.

    Args:
        text: Text to analyze
        wpm: Words per minute reading speed (default: 200)

    Returns:
        Reading time in minutes (minimum 1)

    Example:
        {{ post.content | reading_time }} min read
        {{ post.content | reading_time(250) }} min read

    """
    if not text:
        return 1

    # Strip HTML if present
    clean_text = strip_html(text)

    # Count words
    words = len(clean_text.split())

    # Calculate reading time
    minutes = words / wpm

    # Always return at least 1 minute
    return max(1, round(minutes))


def word_count(text: str) -> int:
    """
    Count words in text.

    Strips HTML tags before counting. Uses same logic as reading_time.

    Args:
        text: Text to count (can contain HTML)

    Returns:
        Number of words

    Example:
        {{ page.word_count }} words  {# Recommended: use computed property #}
        {{ page.word_count }} words ({{ page.reading_time }} min read)

        {# Filter works on any text #}
        {{ custom_text | word_count }} words

    """
    if not text:
        return 0
    clean_text = strip_html(text)
    return len(clean_text.split())


def excerpt(text: str, length: int = 200, respect_word_boundaries: bool = True) -> str:
    """
    Extract excerpt from text, optionally respecting word boundaries.

    Args:
        text: Text to excerpt from
        length: Maximum length in characters
        respect_word_boundaries: Don't cut words in half (default: True)

    Returns:
        Excerpt with ellipsis if truncated

    Example:
        {{ post.content | excerpt(200) }}
        {{ post.content | excerpt(150, false) }}  # Can cut words

    """
    if not text:
        return ""

    # Strip HTML first
    clean_text = strip_html(text)

    if len(clean_text) <= length:
        return clean_text

    if respect_word_boundaries:
        # Find the last space before the limit
        excerpt_text = clean_text[:length].rsplit(" ", 1)[0]
        return excerpt_text + "..."
    else:
        return clean_text[:length] + "..."


def _strip_leading_duplicate(text: str, prefix: str) -> str:
    """Remove leading prefix from text (case-insensitive) plus trailing punctuation."""
    if not prefix or not text:
        return text
    prefix_clean = prefix.strip()
    if not prefix_clean:
        return text
    text_norm = text.strip()
    if not text_norm.lower().startswith(prefix_clean.lower()):
        return text
    # Remove prefix and any trailing punctuation/whitespace
    rest = text_norm[len(prefix_clean) :].lstrip(".,;:!?-\u2014\u2013 \t\n")
    return rest.strip()


def _prepare_html_for_excerpt(html: str) -> str:
    """Add separators between block elements so headers don't run together."""
    if not html or "<" not in html:
        return html
    # Insert space before block-level elements so "Key Features</h2><h3>Fast Builds"
    # becomes "Key Features Fast Builds" after strip_html
    return re.sub(r"</(h[1-6]|p|li|div|ul|ol)>", " ", html, flags=re.I)


def excerpt_for_card(content: str, title: str = "", description: str = "") -> str:
    """
    Strip leading content that duplicates title or description.

    Use for card/tile previews so the excerpt does not repeat the title
    or description. Works with HTML (strips to plain text) or plain text.
    Handles block elements (headers, lists) so they read well when
    collapsed to a single line.

    Args:
        content: Excerpt or description text (HTML or plain)
        title: Page/card title to strip from start
        description: Explicit description to strip if present at start

    Returns:
        Plain text with leading duplicates removed

    Example:
        {{ p.excerpt | excerpt_for_card(p.title, p.description) }}
        {{ item.description | excerpt_for_card(item.title) }}

    """
    if not content:
        return ""
    # Preprocess HTML to add space between block elements (headers, lists)
    prepped = _prepare_html_for_excerpt(content)
    text = strip_html(prepped)
    text = text_utils.normalize_whitespace(text, collapse=True).strip()
    if title:
        text = _strip_leading_duplicate(text, title)
    if description and text:
        text = _strip_leading_duplicate(text, description)
    return text.strip()


def card_excerpt(
    content: str,
    words: int = 30,
    title: str = "",
    description: str = "",
    suffix: str = "...",
) -> str:
    """
    Excerpt for card previews: strip title/description duplicates, then truncate.

    Combines excerpt_for_card and truncatewords. Use for post cards, related
    cards, tiles, and any preview that should not duplicate the title.

    Args:
        content: Excerpt or content (HTML or plain)
        words: Max words to keep (default: 30)
        title: Title to strip from start
        description: Description to strip from start
        suffix: Truncation suffix (default: "...")

    Returns:
        Plain text, truncated at word boundary

    Example:
        {{ p.excerpt | card_excerpt(30, p.title, p.description) | safe }}
        {{ item.description | card_excerpt(25, item.title) | safe }}

    """
    if not content:
        return ""
    cleaned = excerpt_for_card(content, title, description)
    if not cleaned:
        return ""
    return truncatewords(cleaned, words, suffix)


def _find_html_offset_for_plain_chars(html: str, target_plain_chars: int) -> int:
    """Find HTML position where we've output target_plain_chars of plain text (skip tags)."""
    plain_count = 0
    i = 0
    in_tag = False
    while i < len(html) and plain_count < target_plain_chars:
        if html[i] == "<":
            in_tag = True
        elif html[i] == ">":
            in_tag = False
        elif not in_tag:
            plain_count += 1
        i += 1
    return i


def card_excerpt_html(
    content: str,
    words: int = 30,
    title: str = "",
    description: str = "",
    suffix: str = "...",
) -> str:
    """
    Excerpt for card previews with HTML: strip duplicates, truncate preserving tags.

    Use when excerpt contains HTML (e.g. <strong>, <em>, <br>). Strips leading
    title/description duplicates, then truncates with tag-aware truncatewords_html.

    Args:
        content: Excerpt or content (HTML)
        words: Max words to keep (default: 30)
        title: Title to strip from start
        description: Description to strip from start
        suffix: Truncation suffix (default: "...")

    Returns:
        HTML excerpt, truncated at word boundary with tags preserved

    Example:
        {{ p.excerpt | card_excerpt_html(35, p.title, p.description) | safe }}
    """
    if not content:
        return ""
    words = coerce_int(words, 30)
    # Same duplicate stripping as excerpt_for_card
    prepped = _prepare_html_for_excerpt(content) if "<" in content else content
    original_plain = text_utils.normalize_whitespace(strip_html(prepped), collapse=True).strip()
    plain = original_plain
    if title:
        plain = _strip_leading_duplicate(plain, title)
    if description and plain:
        plain = _strip_leading_duplicate(plain, description)
    plain = plain.strip()
    if not plain:
        return ""
    # Find where stripped content starts in HTML (skip leading duplicate)
    removed_chars = len(original_plain) - len(plain)
    if removed_chars > 0:
        offset = _find_html_offset_for_plain_chars(prepped, removed_chars)
        content = prepped[offset:].lstrip()
    return truncatewords_html(content, words, suffix)


def strip_whitespace(text: str) -> str:
    """
    Remove extra whitespace (multiple spaces, newlines, tabs).

    Uses bengal.utils.text.normalize_whitespace internally.
    Replaces all whitespace sequences with a single space.

    Args:
        text: Text to clean

    Returns:
        Text with normalized whitespace

    Example:
        {{ messy_text | strip_whitespace }}

    """
    return text_utils.normalize_whitespace(text, collapse=True)


def first_sentence(text: str, max_length: int = 120) -> str:
    """
    Extract first sentence from text, or truncate if too long.

    Useful for generating short descriptions from longer text blocks.
    Looks for sentence-ending punctuation (. ! ?) followed by whitespace.

    Args:
        text: Text to extract first sentence from
        max_length: Maximum length before truncation (default: 120)

    Returns:
        First sentence or truncated text with ellipsis

    Example:
        {{ page.description | first_sentence }}
        {{ section.metadata.description | first_sentence(80) }}

    """
    if not text:
        return ""

    text = text.strip()

    # Try to find first sentence by looking for sentence-ending punctuation
    for end in [". ", ".\n", "!\n", "?\n", "! ", "? "]:
        if end in text:
            first = text.split(end)[0] + end[0]
            if len(first) <= max_length:
                return first
            break

    # Truncate if too long
    if len(text) > max_length:
        # Try to break at word boundary
        truncated = text[: max_length - 3].rsplit(" ", 1)[0]
        return truncated + "..."

    return text


def filesize(size_bytes: int) -> str:
    """
    Format bytes as human-readable file size.

    Wraps bengal.utils.text.humanize_bytes for template use.

    Args:
        size_bytes: Size in bytes

    Returns:
        Human-readable size string (e.g., "1.5 MB", "256 KB")

    Example:
        {{ asset.size | filesize }}
        {{ page.content | length | filesize }}

    """
    return text_utils.humanize_bytes(size_bytes)


def base64_encode(text: str | None) -> str:
    """
    Encode text as Base64.

    Args:
        text: Text to encode

    Returns:
        Base64 encoded string

    Example:
        {{ "hello" | base64_encode }}  # "aGVsbG8="
        {{ small_image | base64_encode }}  # For data URLs

    """
    if not text:
        return ""

    try:
        return base64.b64encode(text.encode("utf-8")).decode("ascii")
    except Exception:
        return ""


def base64_decode(text: str | None) -> str:
    """
    Decode Base64 text.

    Args:
        text: Base64 encoded string

    Returns:
        Decoded text, or empty string on error

    Example:
        {{ "aGVsbG8=" | base64_decode }}  # "hello"

    """
    if not text:
        return ""

    try:
        return base64.b64decode(text).decode("utf-8")
    except Exception:
        return ""
