# mkdocs-changelog-feed-plugin
# Copyright (C) 2025 Tobias Bölz
#
# This program is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License along with
# this program.  If not, see <http://www.gnu.org/licenses/>.
"""MkDocs changelog feed plugin and config schema."""
import re
from datetime import date, datetime, time, timezone
from itertools import takewhile
from pathlib import Path

from bs4 import BeautifulSoup
from bs4.formatter import EntitySubstitution, HTMLFormatter
from mkdocs.config import config_options
from mkdocs.config.base import Config
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin, get_plugin_logger
from mkdocs.structure.files import File, Files
from mkdocs.structure.pages import Page

from .feeds import feed_classes

log = get_plugin_logger(__name__)


formatter = HTMLFormatter(
    entity_substitution=EntitySubstitution.substitute_html5,
    void_element_close_prefix='',
    empty_attributes_are_booleans=True,
    indent=4,
)


class ChangelogFeedPluginConfig(Config):
    """Plugin config schema.

    See also <https://www.mkdocs.org/dev-guide/plugins/#config_scheme>.
    """

    changelog_file = config_options.Type(str, default='CHANGELOG.md')
    """The file within your `docs` directory containing the changelog."""

    feed_title = config_options.Optional(config_options.Type(str))
    """The feed's title.

    Defaults to "*page name* - *site name*", if not set.
    """

    feed_description = config_options.Optional(config_options.Type(str))
    """The feed's description (RSS)/subtitle (Atom)."""

    remove_permalinks = config_options.Type(bool, default=True)
    """Remove permalinks added by Python-Markdown's Table of Contents extension from
    the feeds.
    """

    links_icon = config_options.Type(str, default='<i class="fa fa-square-rss"></i>')
    """Icon to be displayed next to the links to the feeds at the top of the page."""


class ChangelogFeedPlugin(BasePlugin[ChangelogFeedPluginConfig]):
    """MkDocs plugin that adds RSS and Atom feeds for a changelog in the Keep a
    Changelog format.
    """

    def on_config(self, config: MkDocsConfig):
        """Make sure that the `site_url` setting, which is required to build absolute
        URLs, is present.

        See also <https://www.mkdocs.org/dev-guide/plugins/#on_config>.
        """
        if not config.site_url:
            raise PluginError(
                'The site_url setting is required by the changelog feed plugin.'
            )

        if not config.site_author:
            log.warning(
                'The site_author setting is required to create a valid Atom feed.'
            )

    def on_page_content(
        self, html: str, page: Page, config: MkDocsConfig, files: Files
    ):
        """If `page` is the changelog as specified by the `changelog_file`, this method
        splits the HTML it into sections for each changelog entry and generates RSS and
        Atom feeds with items for each of those entries. Then, links to the feeds are
        added at the top of the page.

        See also <https://www.mkdocs.org/dev-guide/plugins/#on_page_content>.
        """
        if page.file.src_uri != self.config.changelog_file:
            return

        feed_title = self.config.feed_title or f'{page.title} - {config.site_name}'

        feeds = [
            feed_class(
                feed_title,
                page.canonical_url,
                self.config.feed_description,
                author_name=config.site_author,
                feed_copyright=config.copyright,
            )
            for feed_class in feed_classes
        ]

        soup = BeautifulSoup(html, features='html.parser')

        if self.config.remove_permalinks:
            try:
                permalink_class = config['mdx_configs']['toc']['permalink_class']
            except KeyError:
                permalink_class = 'headerlink'
            for permalink in soup.select(f'a.{permalink_class}'):
                permalink.decompose()

        for h2 in soup.find_all('h2'):
            try:
                fragment = h2['id']
            except KeyError:
                fragment = None

            try:
                heading_version, heading_date = (
                    s.strip() for s in re.split(r'\s[-‒––]\s', h2.text, maxsplit=2)
                )
            except ValueError:
                heading_version, heading_date = h2.text, None

            if heading_date:
                try:
                    day = date.fromisoformat(heading_date[:10])
                except ValueError as e:
                    raise PluginError(
                        f'Unable to parse date "{heading_date}" in section heading.'
                    ) from e
                pubdate = datetime.combine(day, time(12, 00), tzinfo=timezone.utc)
            else:
                pubdate = datetime.fromtimestamp(
                    Path(page.file.abs_src_path).stat().st_mtime, tz=timezone.utc
                )

            section_content = ''.join(
                str(s) for s in takewhile(lambda s: s.name != 'h2', h2.next_siblings)
            ).strip()

            for feed in feeds:
                link = (
                    f'{page.canonical_url}#{fragment}'
                    if fragment
                    else page.canonical_url
                )
                feed.add_item(
                    heading_version,
                    link,
                    section_content,
                    pubdate=pubdate,
                    unique_id=link,
                    unique_id_is_permalink=True,
                )

        link_tags = []
        a_tags = []

        for feed in feeds:
            feed_file = File.generated(
                config,
                Path(page.file.src_uri).with_suffix(feed.file_suffix),
                content=feed.writeString('utf-8'),
            )
            files.append(feed_file)

            feed_url = feed_file.url_relative_to(page.file)

            link_tags.append(
                soup.new_tag(
                    'link',
                    attrs={
                        'rel': 'alternate',
                        'type': feed.content_type.split(';')[0],
                        'href': feed_url,
                        'title': f'{page.title} {feed.verbose_name}',
                    },
                )
            )
            a_tags.append(
                soup.new_tag('a', attrs={'href': feed_url}, string=feed.verbose_name)
            )

        page.meta['changelog_feed'] = {'link_tags': link_tags}

        div = soup.new_tag('div', attrs={'style': 'float: inline-end'})
        div.append(BeautifulSoup(self.config.links_icon, 'html.parser'))
        for a_tag in a_tags:
            div.append(' ')
            div.append(a_tag)

        html = '\n'.join([div.decode(formatter=formatter), html])

        return html

    def on_post_page(self, output: str, page: Page, config: MkDocsConfig):
        """Add `<link rel="alternate" …>` tags to the head of the HTML document for
        each generated feed, if stored in the [`Page`][mkdocs.structure.pages.Page]'s
        metadata by
        [`on_page_content`][mkdocs_changelog_feed_plugin.changelog_feed.ChangelogFeedPlugin.on_page_content].

        See also <https://www.mkdocs.org/dev-guide/plugins/#on_post_page>.
        """
        try:
            link_tags = page.meta['changelog_feed']['link_tags']
        except KeyError:
            return

        # Split at the beginning of the line with with the closing head tag.
        parts = re.split(r'^(\s*</head>)', output, maxsplit=1, flags=re.M)

        output = ''.join(
            [
                parts[0],
                *[tag.decode(indent_level=2, formatter=formatter) for tag in link_tags],
                *parts[1:],
            ]
        )

        return output
