# Copyright (C) 2015-2026 Benjamin Mako Hill, Nathan TeBlunthuis, Will
# Beason, Sohyeon Hwang, Kaylea Champion, and other contributors
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.

"""Shared wikitext parser with caching to avoid duplicate parsing."""
from __future__ import annotations

import signal

import mwparserfromhell
from mwparserfromhell.nodes import (
    Argument,
    Comment,
    ExternalLink,
    Heading,
    HTMLEntity,
    Tag,
    Template,
    Text,
    Wikilink,
)

PARSER_TIMEOUT = 60  # seconds


class WikitextParser:
    """Caches parsed wikicode to avoid re-parsing the same text."""

    CITE_TEMPLATES = {
        'cite web', 'cite book', 'cite journal', 'cite news',
        'cite magazine', 'cite conference', 'cite encyclopedia',
        'cite report', 'cite thesis', 'cite press release',
        'citation', 'sfn', 'harvnb', 'harv'
    }

    def __init__(self):
        self._cached_text: str | None = None
        self._cached_wikicode = None
        self.last_parse_timed_out: bool = False
        self._sizes_text: str | None = None
        self._cached_sizes: tuple[int, int] | None = None

    def _timeout_handler(self, signum, frame):
        raise TimeoutError("mwparserfromhell parse exceeded timeout")

    def force_timeout(self, text: str | None) -> None:
        """Pre-populate the cache as if a parse timeout occurred.

        Used by callers that want to skip parsing for revisions predicted to
        hang (e.g. on pages where an earlier same-size revision already timed
        out). Subsequent extract_*() calls will see ``last_parse_timed_out``
        and return None without invoking mwparserfromhell.
        """
        self._cached_text = text
        self._cached_wikicode = None
        self.last_parse_timed_out = True

    def _get_wikicode(self, text: str):
        """Parse text and cache result. Returns cached result if text unchanged."""
        if text == self._cached_text:
            return self._cached_wikicode

        old_handler = signal.signal(signal.SIGALRM, self._timeout_handler)
        signal.alarm(PARSER_TIMEOUT)

        try:
            self._cached_wikicode = mwparserfromhell.parse(text)
            self._cached_text = text
            self.last_parse_timed_out = False
        except TimeoutError:
            self._cached_wikicode = None
            self._cached_text = text
            self.last_parse_timed_out = True
        finally:
            signal.alarm(0)
            signal.signal(signal.SIGALRM, old_handler)

        return self._cached_wikicode

    def extract_external_links(self, text: str | None) -> list[str] | None:
        """Extract all external link URLs from wikitext."""
        if text is None:
            return None
        try:
            wikicode = self._get_wikicode(text)
            return [str(link.url) for link in wikicode.filter_external_links()]
        except Exception:
            return None

    def extract_citations(self, text: str | None) -> list[str] | None:
        """Extract citations from ref tags and cite templates."""
        if text is None:
            return None
        try:
            wikicode = self._get_wikicode(text)
            citations = []

            # Extract ref tag contents
            for tag in wikicode.filter_tags():
                if tag.tag.lower() == 'ref':
                    content = str(tag.contents).strip()
                    if content:
                        citations.append(f"ref:{content}")

            # Extract cite templates
            for template in wikicode.filter_templates():
                template_name = str(template.name).strip().lower()
                if any(template_name.startswith(cite) for cite in self.CITE_TEMPLATES):
                    citations.append(f"template:{str(template)}")

            return citations
        except Exception:
            return None

    def extract_wikilinks(self, text: str | None) -> list[dict[str, str | None]] | None:
        """Extract all internal wikilinks with title and display text."""
        if text is None:
            return None
        try:
            wikicode = self._get_wikicode(text)
            result = []
            for link in wikicode.filter_wikilinks():
                title = str(link.title).strip()
                # text is None if no pipe, otherwise the display text
                display_text = str(link.text).strip() if link.text else None
                result.append({"title": title, "text": display_text})
            return result
        except Exception:
            return None

    def extract_templates(self, text: str | None) -> list[dict] | None:
        """Extract all templates with their names and parameters."""
        if text is None:
            return None
        try:
            wikicode = self._get_wikicode(text)
            result = []
            for template in wikicode.filter_templates():
                name = str(template.name).strip()
                params = {}
                for param in template.params:
                    param_name = str(param.name).strip()
                    param_value = str(param.value).strip()
                    params[param_name] = param_value
                result.append({"name": name, "params": params})
            return result
        except Exception:
            return None

    def _count_content(self, wikicode, counts: list[int]) -> None:
        """Walk a wikicode tree adding the length of rendered content.

        counts is a one-element list holding the running content total.
        Anything not counted here is markup; content_sizes() derives the
        markup total by subtraction, so every character lands in one column
        or the other.
        """
        for node in wikicode.nodes:
            if isinstance(node, Text):
                counts[0] += len(node.value)
            elif isinstance(node, HTMLEntity):
                # an entity stands in for the character it renders as, so
                # only that character is content and the escaping is markup
                counts[0] += len(node.normalize())
            elif isinstance(node, Comment):
                # never reaches a reader, so it is not information
                continue
            elif isinstance(node, Template):
                # the parameter values are the information; the template
                # name, parameter names and delimiters are the polish that
                # someone later wrapped around it
                for param in node.params:
                    self._count_content(param.value, counts)
            elif isinstance(node, Tag):
                # tag contents are information; the tag name and attributes
                # are markup. This covers ''italic'' and '''bold''', which
                # mwparserfromhell represents as i and b tags
                if node.contents is not None:
                    self._count_content(node.contents, counts)
            elif isinstance(node, Wikilink):
                # the label if present, otherwise the target, is what a
                # reader sees; the brackets and the link target are markup
                rendered = node.text if node.text is not None else node.title
                self._count_content(rendered, counts)
            elif isinstance(node, ExternalLink):
                # both halves are information someone typed: a contributor
                # who did not know wikitext would still have written the URL
                counts[0] += len(str(node.url))
                if node.title is not None:
                    self._count_content(node.title, counts)
            elif isinstance(node, Heading):
                self._count_content(node.title, counts)
            elif isinstance(node, Argument):
                if node.default is not None:
                    self._count_content(node.default, counts)

    def content_sizes(self, text: str | None) -> tuple[int, int] | None:
        """Split a revision into the information it carries and the markup
        around it.

        Returns (content_chars, markup_chars), which sum to len(text).

        Content is what a contributor who did not know wikitext would have
        typed: the words, the link labels, the values inside template
        parameters. Markup is everything a later editor added to format it —
        braces, pipes, parameter names, tag names and attributes, link
        targets, quote marks, and comments.

        So "Wonderful Hotel, +15555550123" is almost all content, while
        "{{listing|name=Wonderful Hotel|phone=+15555550123}}" carries the
        same information with two dozen characters of markup around it.
        """
        if text is None:
            return None
        if text == self._sizes_text:
            return self._cached_sizes
        try:
            wikicode = self._get_wikicode(text)
            counts = [0]
            self._count_content(wikicode, counts)
            content = min(counts[0], len(text))
            result = (content, len(text) - content)
        except Exception:
            result = None
        self._sizes_text = text
        self._cached_sizes = result
        return result

    def extract_headings(self, text: str | None) -> list[dict] | None:
        """Extract all section headings with their levels."""
        if text is None:
            return None
        try:
            wikicode = self._get_wikicode(text)
            result = []
            for heading in wikicode.filter_headings():
                level = heading.level
                heading_text = str(heading.title).strip()
                result.append({"level": level, "text": heading_text})
            return result
        except Exception:
            return None
