import copy
import datetime
import logging
import os
from collections.abc import Callable, Generator, Iterable
from multiprocessing.pool import ThreadPool
from pathlib import Path
from typing import Any, cast

import dateutil.parser as dateparse
from more_itertools import batched
from render_engine_parser import BasePageParser
from slugify import slugify

from ._base_object import BaseObject
from .archive import Archive
from .content_managers import ContentManager, FileContentManager
from .feeds import RSSFeed
from .page import Page
from .plugins import PluginManager


class Collection(BaseObject):
    """
    Collection objects serve as a way to quickly process pages that have a
    portion of content that is similar or file driven.

    Example:

    ```python
    from render_engine import Site, Collection

    site = Site()

    @site.collection
    class BasicCollection(Collection):
        content_path = "content/pages"
    ```

    Collection pages **MUST** come from a `content_path` and all be the same
    content type.  `content_path` can be a string representing a path or URL,
    depending on the [parser][src.render_engine.parsers.base_parsers] used.

    Attributes:

        archive_template: The template to use for the [`Archive`][src.render_engine.archive.Archive] pages.
        content_path: The path to iterate over to generate pages.
        content_type: Type[Page] = Page
        Feed: Type[RSSFeed] = RSSFeed
        feed_title: str
        include_suffixes: list[str] = ["*.md", "*.html"]
        items_per_page: int | None
        Parser: BasePageParser = BasePageParser
        parser_extras: dict[str, Any]
        required_themes: list[callable]
        routes: list[str | Path] = ["./"]
        sort_by: str | list = "title"
        sort_reverse: bool = False
        title: str
        template: str | None
        archive_template str | None: The template to use for the archive pages.
        ContentManager: type[ContentManager] | None = FileContentManager
        content_manager: ContentManager
        content_manager_extras: dict[str, Any]: kwargs to pass to the ContentManager when instantiating

    Methods:

        iter_content_path(): Iterates through the collection's content path.
        get_page(content_path: str | Path | None = None): Returns the page Object for the specified Content Path.
        sorted_pages: Returns the sorted pages of the collection.
        archives: Returns the Archive objects containing the pages from the content path.
        feed: Returns the Feed object for the collection.
        slug: Returns the slugified title of the collection.

    """

    archive_template: str | Path | None = "archive.html"
    content_path: Path | str
    content_type: type[Page] = Page
    Feed: type[RSSFeed] = RSSFeed
    feed_title: str
    include_suffixes: list[str] = ["*.md", "*.html"]
    items_per_page: int | None
    Parser: type[BasePageParser] = BasePageParser
    parser_extras: dict[str, Any]
    required_themes: list[Callable]
    routes: list[str | Path] = ["./"]
    site = None
    sort_by: str | list = "_title"
    sort_reverse: bool = False
    template_vars: dict[str, Any]
    template: str | None
    plugin_manager: PluginManager | None
    ContentManager: type[ContentManager] = FileContentManager
    content_manager_extras: dict[str, Any]

    def __init__(
        self,
    ) -> None:
        if parser := getattr(self, "PageParser", None):
            logging.warning(
                DeprecationWarning(
                    f"The deprecated`PageParser` attribute is used in `{self.__class__.__name__}`. \
                        Use the `Parser` attribute instead."
                )
            )
            self.Parser = parser

        if getattr(self, "items_per_page", False):
            self.has_archive = True
        self.title = self._title
        self.template_vars = getattr(self, "template_vars", {})

        cm_extras = {
            "content_path": getattr(self, "content_path", None),
            "include_suffixes": getattr(self, "include_suffixes", None),
            "collection": self,
        }
        if hasattr(self, "content_manager_extras"):
            cm_extras.update(self.content_manager_extras)
        self.content_manager = self.ContentManager(**cm_extras)
        if hasattr(self, "pages"):
            self.content_manager.pages = cast(Iterable, self.pages)

    def get_page(
        self,
        content_path: str | Path | None = None,
    ) -> Page:
        """Returns the page Object for the specified Content Path"""
        _page = self.content_type(
            content_path=content_path,
            Parser=self.Parser,
        )

        _page.parser_extras = getattr(self, "parser_extras", {})
        _page.routes = self.routes
        _page.template = getattr(self, "template", None)
        _page.collection = self.to_dict()

        return _page

    @staticmethod
    def _date_key(page: Page) -> datetime.datetime:
        """
        Key for ensuring proper handling of dates for sorting
        There are 2 issues here:
        1. We need to convert a string to a datetime for proper sorting
        2. We need to strip the timezone so that we are consistently dealing with timezone naive objects

        :param page: The Page object to handle the date for
        :return: Timezone naive datetime object
        """
        date = getattr(page, "date")
        _date = dateparse.parse(date) if isinstance(date, str) else copy.copy(date)
        return _date.replace(tzinfo=None) if isinstance(_date, datetime.datetime) else _date

    def _sort_key(self, key: str | list[str]) -> Callable:
        """
        Dynamically generate the sorting key

        :param key: The key to use
        """
        if isinstance(key, str):
            return self._date_key if key == "date" else lambda page: getattr(page, key)
        return lambda page: [getattr(page, attr) for attr in key]

    @property
    def sorted_pages(self):
        """
        Returns pages in the collection sorted by the `self.sort_by` attribute.

        Exceptions:
            AttributeError: This is raised when the attribute is missing from one or more pages
            TypeError: This happens when the values being compared are of two different types

        """
        try:
            return sorted(
                (page for page in self),
                key=self._sort_key(self.sort_by),
                reverse=self.sort_reverse,
            )
        except AttributeError as e:
            raise AttributeError(
                f"Cannot sort pages: '{self.sort_by}' attribute is missing from one or more pages."
                f"Make sure all pages in collection '{self._title}' have the '{self.sort_by}' attribute defined."
            ) from e

    @property
    def archives(self) -> Generator[Archive, None, None]:
        """
        Returns a [Archive][src.render_engine.archive.Archive] objects containing the pages from the `content_path` .

        Archives are an iterable and the individual pages are built shortly after the collection pages are built.
        This happens when [Site.render][render_engine.Site.render] is called.
        """
        if not getattr(self, "has_archive", False):
            logging.warning(
                "`has_archive` is set to `False` for %s. While an archive will be generated. \
                The file will not be saved.",
                self._title,
            )
            yield from ()

        sorted_pages = list(self.sorted_pages)
        items_per_page = getattr(self, "items_per_page", len(sorted_pages))
        archives = [sorted_pages]

        if items_per_page != len(sorted_pages):
            paginated_archives = list(batched(sorted_pages, items_per_page))
            archives.extend(paginated_archives)
            self.template_vars["num_of_pages"] = len(paginated_archives)
        else:
            self.template_vars["num_of_pages"] = 1

        for index, pages in enumerate(archives):
            yield Archive(
                pages=pages,
                template=getattr(self, "archive_template", None),
                template_vars=self.template_vars,
                title=self._title,
                routes=self.routes,
                archive_index=index,
                plugin_manager=getattr(self, "plugin_manager", None),
                is_index=not index,
            )

    @property
    def feed(self):
        feed = self.Feed()
        feed.pages = self.sorted_pages
        feed.title = getattr(self, "feed_title", self._title)
        feed.slug = self._slug
        feed.Parser = self.Parser
        return feed

    @property
    def slug(self):
        return slugify(self.title)

    def __repr__(self):
        return f"{self}: {__name__}"

    def __str__(self):
        return f"{__name__}"

    def __iter__(self):
        yield from self.content_manager

    def __len__(self):
        return len(self.content_manager)

    @property
    def all_content(self) -> Generator:
        """All of the content that is associated with the Collection including Pages, Feed, and Archives"""
        yield from self

        if getattr(self, "has_archive", False):
            for archive in self.archives:
                yield archive
                if archive.is_index:
                    # In order to avoid collision with parallel processing we need to do a copy.
                    # A deepcopy is not necessary because we only care about not overwriting the
                    # slug on the original.
                    index = copy.copy(archive)
                    index.slug = "index"
                    yield index

        if feed := getattr(self, "feed", None):
            yield feed

    def _run_collection_plugins(self, site, hook_type: str):
        """
        Run plugins for a collection

        :param site: The site object triggering the call
        :param hook_type: The hook to run
        """
        self.plugin_manager = cast(PluginManager, self.plugin_manager)
        if not getattr(self.plugin_manager, "_pm", None) or not self.plugin_manager.plugins:
            return
        try:
            method = getattr(self.plugin_manager.hook, hook_type)
        except AttributeError:
            logging.error(f"Unknown {hook_type=}")
            return
        method(collection=self, site=site, settings=self.plugin_manager.plugin_settings)

    def _render(self, entry: BaseObject):
        """
        Renders 1 entry in the Collection

        :param entry: The entry to process
        """
        if not isinstance(entry, RSSFeed) and not isinstance(entry, Archive):
            entry.plugin_manager: PluginManager = copy.deepcopy(self.plugin_manager)

        # Circular imports. Need to be handled here.
        from .page import BasePage
        from .site import Site

        entry = cast(BasePage, entry)
        self = cast(Collection, self)
        self.site = cast(Site, self.site)
        entry.site = self.site
        entry.render(self.site.theme_manager)

    def render(self) -> None:
        """Iterate through Pages and Check for Archives and Feeds"""

        # Use a ThreadPool to process all of the entries in the collection in parallel.
        # This is limited to the number of CPUs available. The easiest way to implement
        # this parallelization for a single task is to use the imap_unordered method of
        # the ThreadPool. This is, effectively, a generator so we need to loop over it
        # for them to run. Since there is no actual return value to look at we just `pass`.
        for entry in ThreadPool(processes=os.cpu_count()).imap_unordered(self._render, self.all_content):
            pass

    def create_entry(
        self,
        filepath: Path | None = None,
        editor: str | None = None,
        content: str | None = None,
        metadata: dict | None = None,
    ) -> str:
        """
        Create a new entry for the Collection

        :param filepath: Path object for the new entry
        :param editor: Editor to open to edit the entry.
        :param content: Content for the new entry
        :param metadata: Metadata for the new entry
        """
        context = copy.deepcopy(self._metadata_attrs())
        if metadata:
            context.update(metadata)
        return self.content_manager.create_entry(
            filepath=filepath, editor=editor, metadata=context, content=content or "Hello, world!"
        )


def render_archives(archive, **kwargs) -> list[Archive]:
    return [archive.render(pages=archive.pages, **kwargs) for archive in archive]
