# 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/>.

import re
from abc import abstractmethod, ABC
from datetime import datetime, timezone
from hashlib import sha1
from typing import Generic, TypeVar, Union, TYPE_CHECKING

import mwreverts
import mwtypes
import mwxml

import pyarrow as pa

if TYPE_CHECKING:
    from wikiq.wikitext_parser import WikitextParser

T = TypeVar('T')


class RevisionField(ABC, Generic[T]):
    """
    Abstract type which represents a field in a table of page revisions.
    """

    @property
    @abstractmethod
    def field(self) -> pa.Field:
        pass

    @abstractmethod
    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> T:
        """
        :param page: The page for this set of revisions.
        :param revisions: The set of revisions to compute the field from.
        Revisions are passed in chronological order, so use revisions[-1] to
        access the most recent revision in the set.

        Implementations of extract should handle the case where revisions is
        either a single revision (collapse-user=FALSE), or a full edit session
        of contiguous edits by the same user (collapse-user=TRUE).
        """
        pass


class RevisionTable:
    columns: list[RevisionField]

    def __init__(self, columns: list[RevisionField]):
        self.columns = columns

    def schema(self) -> pa.Schema:
        return pa.schema([c.field for c in self.columns])

    def extract_row(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> dict:
        """Extract a single row dict for the given page and revisions."""
        return {
            column.field.name: column.extract(page, revisions)
            for column in self.columns
        }


class RevisionId(RevisionField[int]):
    field = pa.field("revid", pa.int64())

    def extract(self, _: mwtypes.Page, revisions: list[mwxml.Revision]) -> int:
        revision = revisions[-1]
        return revision.id


class RevisionTimestamp(RevisionField[datetime]):
    field = pa.field("date_time", pa.timestamp('s'))

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> datetime:
        revision = revisions[-1]
        return revision.timestamp


class RevisionArticleId(RevisionField[int]):
    field = pa.field("articleid", pa.int64())

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> int:
        return page.id


class RevisionEditorId(RevisionField[Union[int, None]]):
    field = pa.field("editorid", pa.int64(), nullable=True)

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[int, None]:
        revision = revisions[-1]
        if revision.deleted.user:
            return None

        return revision.user.id


class RevisionEditSummary(RevisionField[Union[str, None]]):
    field = pa.field("edit_summary", pa.string(), nullable=True)

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[str, None]:
        revision = revisions[-1]

        return revision.comment

class RevisionIsAnon(RevisionField[Union[bool, None]]):
    field = pa.field("anon", pa.bool_(), nullable=True)

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[bool, None]:
        revision = revisions[-1]
        if revision.deleted.user:
            return None

        return revision.user.id is None


class RevisionEditorText(RevisionField[Union[str, None]]):
    field = pa.field("editor", pa.string(), nullable=True)

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[str, None]:
        revision = revisions[-1]
        if revision.deleted.user:
            return None

        return revision.user.text


class RevisionPageTitle(RevisionField[str]):
    """The page's title as of the time of export.

    This is a page-level identity value, not a historical fact about the
    revision: a moved page's entire history carries its final title.
    """

    field = pa.field("title", pa.string())

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> str:
        return page.title


class RevisionDeleted(RevisionField[bool]):
    field = pa.field("deleted", pa.bool_())

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> bool:
        revision = revisions[-1]
        return revision.deleted.text


class RevisionNamespace(RevisionField[int]):
    """The page's namespace as of the time of export.

    Like the title it derives from, this is a page-level identity value
    as of export, not a historical fact about the revision.
    """

    field = pa.field("namespace", pa.int32())

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> int:
        return page.namespace


class RedirectDetector:
    """Detect a redirect directive at the start of revision text.

    #REDIRECT is valid on every MediaWiki regardless of content language.
    Localized aliases (e.g. OMDIRIGERING on Swedish wikis) are
    additionally valid per wiki and can be supplied by the caller.
    """

    def __init__(self, aliases: Union[list[str], None] = None):
        keywords = ["REDIRECT"] + list(aliases or [])
        alternation = "|".join(re.escape(k) for k in keywords)
        # target extraction stops at | (label) and # (fragment)
        self.pattern = re.compile(
            r"\A\s*#(?:" + alternation + r")\s*:?\s*\[\[([^\[\]|#]+)",
            re.IGNORECASE,
        )

    def detect(self, text: str) -> tuple[bool, Union[str, None]]:
        """Return (is_redirect, target); target is None when not a redirect."""
        match = self.pattern.match(text)
        if match is None:
            return False, None
        return True, match.group(1).strip()


class RevisionIsRedirect(RevisionField[Union[bool, None]]):
    """Whether this revision's own text begins with a redirect directive."""

    field = pa.field("revision_is_redirect", pa.bool_(), nullable=True)

    def __init__(self, detector: RedirectDetector):
        super().__init__()
        self.detector = detector

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[bool, None]:
        revision = revisions[-1]
        if revision.deleted.text or revision.text is None:
            return None
        return self.detector.detect(revision.text)[0]


class RevisionRedirectTarget(RevisionField[Union[str, None]]):
    """The link target of this revision's redirect directive, if any."""

    field = pa.field("revision_redirect_target", pa.string(), nullable=True)

    def __init__(self, detector: RedirectDetector):
        super().__init__()
        self.detector = detector

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[str, None]:
        revision = revisions[-1]
        if revision.deleted.text or revision.text is None:
            return None
        return self.detector.detect(revision.text)[1]


class RevisionSha1(RevisionField[str]):
    field = pa.field("sha1", pa.string())

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> str:
        revision = revisions[-1]
        return revision.sha1


class RevisionTextChars(RevisionField[Union[int, None]]):
    field = pa.field("text_chars", pa.int32(), nullable=True)

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[int, None]:
        revision = revisions[-1]
        if not revision.deleted.text:
            return len(revision.text)

        return None

class RevisionIsMinor(RevisionField[bool]):
    field = pa.field("minor", pa.bool_())

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> bool:
        revision = revisions[-1]
        return revision.minor


class RevisionReverts(RevisionField[Union[str, None]]):
    def __init__(self):
        super().__init__()
        self.rev_detector: Union[mwreverts.Detector, None] = None

    field = pa.field("reverteds", pa.string(), nullable=True)

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[str, None]:
        if self.rev_detector is None:
            return None

        revision = revisions[-1]
        if revision.deleted.text:
            return None

        revert = self.rev_detector.process(revision.sha1, revision.id)
        if revert is None:
            return None

        return ",".join([str(s) for s in revert.reverteds])


class RevisionCollapsed(RevisionField[int]):
    field = pa.field("collapsed_revs", pa.int64())

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> int:
        return len(revisions)


class RevisionText(RevisionField[str]):
    field = pa.field("text", pa.string(), nullable=False)

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> str:
        revision = revisions[-1]
        return revision.text


class RevisionExternalLinks(RevisionField[Union[list[str], None]]):
    """Extract all external links from revision text."""

    field = pa.field("external_links", pa.list_(pa.string()), nullable=True)

    def __init__(self, wikitext_parser: "WikitextParser"):
        super().__init__()
        self.wikitext_parser = wikitext_parser

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[list[str], None]:
        revision = revisions[-1]
        if revision.deleted.text:
            return None
        return self.wikitext_parser.extract_external_links(revision.text)


class RevisionCitations(RevisionField[Union[list[str], None]]):
    """Extract citations from ref tags and cite templates."""

    field = pa.field("citations", pa.list_(pa.string()), nullable=True)

    def __init__(self, wikitext_parser: "WikitextParser"):
        super().__init__()
        self.wikitext_parser = wikitext_parser

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[list[str], None]:
        revision = revisions[-1]
        if revision.deleted.text:
            return None
        return self.wikitext_parser.extract_citations(revision.text)


class RevisionWikilinks(RevisionField[Union[list[dict], None]]):
    """Extract all internal wikilinks from revision text."""

    # Struct type with title and optional display text
    field = pa.field("wikilinks", pa.list_(pa.struct([
        pa.field("title", pa.string()),
        pa.field("text", pa.string(), nullable=True),
    ])), nullable=True)

    def __init__(self, wikitext_parser: "WikitextParser"):
        super().__init__()
        self.wikitext_parser = wikitext_parser

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[list[dict], None]:
        revision = revisions[-1]
        if revision.deleted.text:
            return None
        return self.wikitext_parser.extract_wikilinks(revision.text)


class RevisionTemplates(RevisionField[Union[list[dict], None]]):
    """Extract all templates from revision text."""

    # Struct type with name and params map
    field = pa.field("templates", pa.list_(pa.struct([
        pa.field("name", pa.string()),
        pa.field("params", pa.map_(pa.string(), pa.string())),
    ])), nullable=True)

    def __init__(self, wikitext_parser: "WikitextParser"):
        super().__init__()
        self.wikitext_parser = wikitext_parser

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[list[dict], None]:
        revision = revisions[-1]
        if revision.deleted.text:
            return None
        return self.wikitext_parser.extract_templates(revision.text)


class RevisionHeadings(RevisionField[Union[list[dict], None]]):
    """Extract all section headings from revision text."""

    # Struct type with level and text
    field = pa.field("headings", pa.list_(pa.struct([
        pa.field("level", pa.int8()),
        pa.field("text", pa.string()),
    ])), nullable=True)

    def __init__(self, wikitext_parser: "WikitextParser"):
        super().__init__()
        self.wikitext_parser = wikitext_parser

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[list[dict], None]:
        revision = revisions[-1]
        if revision.deleted.text:
            return None
        return self.wikitext_parser.extract_headings(revision.text)


class RevisionContentChars(RevisionField[Union[int, None]]):
    """Count the characters of information a revision carries.

    The words, link labels and template parameter values -- what someone
    who did not know wikitext would have typed. See
    WikitextParser.content_sizes().
    """

    field = pa.field("content_chars", pa.int64(), nullable=True)

    def __init__(self, wikitext_parser: "WikitextParser"):
        super().__init__()
        self.wikitext_parser = wikitext_parser

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[int, None]:
        revision = revisions[-1]
        if revision.deleted.text:
            return None
        sizes = self.wikitext_parser.content_sizes(revision.text)
        return None if sizes is None else sizes[0]


class RevisionMarkupChars(RevisionField[Union[int, None]]):
    """Count the characters of markup wrapped around a revision's content.

    Braces, pipes, parameter names, tag names and attributes, link targets
    and comments. Together with content_chars this sums to the revision
    length.
    """

    field = pa.field("markup_chars", pa.int64(), nullable=True)

    def __init__(self, wikitext_parser: "WikitextParser"):
        super().__init__()
        self.wikitext_parser = wikitext_parser

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[int, None]:
        revision = revisions[-1]
        if revision.deleted.text:
            return None
        sizes = self.wikitext_parser.content_sizes(revision.text)
        return None if sizes is None else sizes[1]


class RevisionParserTimeout(RevisionField[bool]):
    """Track whether the wikitext parser timed out for this revision."""

    field = pa.field("parser_timeout", pa.bool_())

    def __init__(self, wikitext_parser: "WikitextParser"):
        super().__init__()
        self.wikitext_parser = wikitext_parser

    def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> bool:
        return self.wikitext_parser.last_parse_timed_out
