from __future__ import annotations

import contextlib
import datetime
import logging
import os
import shutil
import stat
import tempfile
from collections.abc import Sequence
from contextlib import ExitStack, contextmanager
from functools import cached_property
from typing import TYPE_CHECKING, Any, Union
from unittest import TestCase, mock

import pytz

from staticsite.cmd.build import Builder
from staticsite.file import File
from staticsite.settings import Settings
from staticsite.site import Site
from staticsite.utils import front_matter

if TYPE_CHECKING:
    from staticsite.page import Page

MockFiles = dict[str, Union[str, bytes, dict]]

project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))


class MockSiteBase:
    """
    Common code for different ways of setting up mock sites
    """

    def __init__(
        self, auto_load_site: bool = True, settings: dict[str, Any] | None = None
    ):
        # Set to False if you only want to populate the workdir
        self.auto_load_site = auto_load_site
        self.stack = contextlib.ExitStack()
        self.root: str | None = None
        self.test_case: TestCase | None = None
        self.settings = Settings()

        self.settings.CACHE_REBUILDS = False
        self.settings.THEME_PATHS = [os.path.join(project_root, "themes")]
        self.settings.TIMEZONE = "Europe/Rome"

        if settings is not None:
            for k, v in settings.items():
                setattr(self.settings, k, v)

        # Timestamp used for mock files and site generation time
        # date +%s --date="2019-06-01 12:30"
        self.generation_time: int | None = 1559385000

        # Mock stat to return this mtime for files scanned during site load
        # date +%s --date="2019-06-01 12:30"
        self.mock_file_mtime: int | None = 1559385000

        self.root = self.stack.enter_context(tempfile.TemporaryDirectory())
        self.builder: Builder | None = None

    def populate_workdir(self):
        raise NotImplementedError(
            f"{self.__class__.__name__}.populate_workdir not implemented"
        )

    def create_site(self) -> Site:
        return Site(
            self.settings,
            generation_time=(
                datetime.datetime.fromtimestamp(self.generation_time, pytz.utc)
                if self.generation_time
                else None
            ),
        )

    @cached_property
    def site(self) -> Site:
        return self.create_site()

    @cached_property
    def build_root(self) -> str:
        return self.stack.enter_context(tempfile.TemporaryDirectory())

    def load_site(self, until=Site.LOAD_STEP_ALL):
        if self.mock_file_mtime:
            overrides = {"st_mtime": self.mock_file_mtime}
        else:
            overrides = None
        with mock_file_stat(overrides):
            self.site.load(until=until)

    def build_site(self):
        """
        Build the site in a temporary directory.

        The build directory will be available as self.build_root.

        The Builder object will be available as self.builder
        """
        self.site.settings.OUTPUT = self.build_root
        self.builder = Builder(self.site)
        self.builder.write()

    def page(self, *paths: tuple[str]) -> tuple[Page]:
        """
        Ensure the site has the given page, by path, and return it
        """
        res: list[Page] = []
        for path in paths:
            page = self.site.root.resolve_path(path)
            if page is None:
                self.test_case.fail(f"Page {path!r} not found in site")
            res.append(page)
        if len(res) == 1:
            return res[0]
        else:
            return tuple(res)

    def assertPagePaths(self, paths: Sequence[str]):
        """
        Check that the list of pages in the site matches the given paths
        """
        self.test_case.assertCountEqual(
            [p.site_path for p in self.site.iter_pages(static=False)], paths
        )

    def _find_page_by_relpath(self, relpath: str) -> Page | None:
        for page in self.site.iter_pages():
            if page.src.relpath == relpath:
                return page
        return None

    def assertBuilt(
        self,
        srcpath: str | None,
        sitepath: str,
        dstpath: str,
        sample: str | bytes | None = None,
    ):
        """
        Check that page at `srcpath` is in the site at `sitepath` and rendered in `dstpath`.

        `srcpath` can be None for autogenerated pages.

        Optionally check that the rendered content contains `sample`
        """
        if not self.builder:
            self.build_site()

        page = self.page(sitepath)
        if srcpath is not None:
            if page is None:
                found = self._find_page_by_relpath(srcpath)
                if found is None:
                    self.test_case.fail(
                        "{sitepath!r} not found in site, and {srcpath} not found in website"
                    )
                else:
                    self.test_case.fail(
                        "{sitepath!r} not found in site, and {srcpath} found at {found} instead"
                    )
            elif page.src.relpath != srcpath:
                found = self._find_page_by_relpath(srcpath)
                if found is None:
                    self.test_case.fail(
                        f"{sitepath!r} found in site, but for {page.src.relpath} instead of {srcpath}."
                        f" {srcpath} not found in website"
                    )
                else:
                    self.test_case.fail(
                        f"{sitepath!r} found in site, but for {page.src.relpath} instead of {srcpath}."
                        f" {srcpath} is instead at {found}"
                    )

        rendered = self.builder.build_log.get(dstpath)
        if rendered is None:
            for path, pg in self.builder.build_log.items():
                if pg == page:
                    self.test_case.fail(
                        f"{dstpath!r} not found in render log; {sitepath!r} was rendered as {path!r} instead"
                    )
                    break
            else:
                self.test_case.fail(f"{dstpath!r} not found in render log")

        if rendered != page:
            for path, pg in self.builder.build_log.items():
                if pg == page:
                    self.test_case.fail(
                        f"{dstpath!r} rendered {rendered!r} instead of {page!r}."
                        " {sitepath!r} was rendered as {path!r} instead"
                    )
                    break
            else:
                self.test_case.fail(
                    f"{dstpath!r} rendered {rendered!r} instead of {page!r}"
                )

        if os.path.isdir(os.path.join(self.build_root, dstpath)):
            self.test_case.fail(f"{dstpath!r} rendered as a directory")

        if sample is not None:
            if isinstance(sample, bytes):
                args = {"mode": "rb"}
            else:
                args = {"mode": "rt", "encoding": "utf-8"}
            with open(os.path.join(self.build_root, dstpath), **args) as fd:
                if sample not in (body := fd.read()):
                    self.test_case.fail(
                        f"{dstpath!r} does not contain {sample!r}. Renrered contents: {body!r}"
                    )

    def __enter__(self) -> MockSite:
        self.populate_workdir()
        if self.auto_load_site:
            self.load_site()
        return self

    def __exit__(self, *args):
        self.site = None
        self.builder = None
        self.build_root = None
        self.stack.__exit__(*args)


class MockSite(MockSiteBase):
    """
    Define a mock site for testing
    """

    def __init__(self, files: MockFiles, **kw):
        super().__init__(**kw)
        self.files = files

        # Default settings for testing
        self.settings.SITE_NAME = "Test site"
        self.settings.SITE_URL = "https://www.example.org"
        self.settings.SITE_AUTHOR = "Test User"

    def populate_workdir(self):
        self.settings.PROJECT_ROOT = self.root

        for relpath, content in self.files.items():
            abspath = os.path.join(self.root, relpath)
            os.makedirs(os.path.dirname(abspath), exist_ok=True)
            if isinstance(content, str):
                with open(abspath, "wt") as fd:
                    fd.write(content)
                    os.utime(fd.fileno(), (self.generation_time, self.generation_time))
            elif isinstance(content, bytes):
                with open(abspath, "wb") as fd:
                    fd.write(content)
                    os.utime(fd.fileno(), (self.generation_time, self.generation_time))
            elif isinstance(content, dict):
                with open(abspath, "wt") as fd:
                    fd.write(front_matter.write(content, style="json"))
                    os.utime(fd.fileno(), (self.generation_time, self.generation_time))
            else:
                raise TypeError("content should be a str or bytes")


class ExampleSite(MockSiteBase):
    """
    Site taken from the example/ directory
    """

    def __init__(self, name: str, **kw):
        super().__init__(**kw)
        self.name = name

        # date +%s --date="2020-02-01 16:00 Z"
        self.generation_time = 1580572800

    def populate_workdir(self):
        src = os.path.join(project_root, "example", self.name)
        # dst = os.path.join(self.workdir, "site")
        os.rmdir(self.root)
        shutil.copytree(src, self.root)

        settings_path = os.path.join(self.root, "settings.py")
        if os.path.exists(settings_path):
            self.settings.load(settings_path)
        if self.settings.PROJECT_ROOT is None:
            self.settings.PROJECT_ROOT = self.root
        if self.settings.SITE_URL is None:
            self.settings.SITE_URL = "http://localhost"


class MockSiteTestMixin:
    """
    Test a site built from a mock description
    """

    @contextmanager
    def site(self, mocksite: MockSite | MockFiles, **kw):
        if not isinstance(mocksite, MockSite):
            mocksite = MockSite(mocksite, **kw)
        mocksite.test_case = self
        with mocksite:
            yield mocksite


@contextmanager
def mock_file_stat(overrides: dict[int, Any] | None):
    """
    Override File.stat contents.

    Overrides is a dict like: `{"st_mtime": 12345}`
    """
    if not overrides:
        yield
        return

    real_stat = os.stat
    real_file_from_dir_entry = File.from_dir_entry

    # See https://www.peterbe.com/plog/mocking-os.stat-in-python
    def mock_stat(*args, **kw):
        res = list(real_stat(*args, **kw))
        for k, v in overrides.items():
            res[getattr(stat, k.upper())] = v
        return os.stat_result(res)

    def mock_file_from_dir_entry(dir, entry):
        res = real_file_from_dir_entry(dir, entry)
        st = list(res.stat)
        for k, v in overrides.items():
            st[getattr(stat, k.upper())] = v
        res = File(res.relpath, res.abspath, os.stat_result(st))
        return res

    with mock.patch("staticsite.file.os.stat", new=mock_stat):
        with mock.patch(
            "staticsite.file.File.from_dir_entry", new=mock_file_from_dir_entry
        ):
            yield


def datafile_abspath(relpath):
    test_root = os.path.dirname(__file__)
    return os.path.join(test_root, "data", relpath)


class TracebackHandler(logging.Handler):
    def __init__(self, *args, **kw):
        super().__init__(*args, **kw)
        self.collected = []

    def handle(self, record):
        import traceback

        if record.stack_info is None:
            record.stack_info = traceback.print_stack()
        self.collected.append(record)


@contextmanager
def assert_no_logs(level=logging.WARN):
    handler = TracebackHandler(level=level)
    try:
        root_logger = logging.getLogger()
        root_logger.addHandler(handler)
        yield
    finally:
        root_logger.removeHandler(handler)
    if handler.collected:
        raise AssertionError(f"{len(handler.collected)} unexpected loggings")


class Args:
    """
    Mock argparser namespace initialized with options from constructor
    """

    def __init__(self, **kw):
        self._args = kw

    def __getattr__(self, k):
        return self._args.get(k, None)


class SiteTestMixin:
    """
    Test a real site found on disk
    """

    site_name: str
    site_settings: dict[str, Any] = {}
    site_cls = ExampleSite

    @classmethod
    def setUpClass(cls):
        super().setUpClass()

        cls.stack = ExitStack()

        cls.mocksite = cls.stack.enter_context(
            cls.site_cls(name=cls.site_name, settings=cls.site_settings)
        )
        cls.site = cls.mocksite.site

        cls.mocksite.build_site()
        cls.build_root = cls.mocksite.build_root
        cls.build_log = cls.mocksite.builder.build_log

    @classmethod
    def tearDownClass(cls):
        cls.mocksite.__exit__(None, None, None)
        cls.stack.__exit__(None, None, None)
        super().tearDownClass()

    def setUp(self):
        super().setUp()
        self.mocksite.test_case = self

    def tearDown(self):
        self.mocksite.test_case = None
        super().tearDown()

    def page(self, *paths: tuple[str]) -> tuple[Page]:
        """
        Ensure the site has the given page, by path, and return it
        """
        return self.mocksite.page(*paths)

    def assertPagePaths(self, paths: Sequence[str]):
        """
        Check that the list of pages in the site matches the given paths
        """
        return self.mocksite.assertPagePaths(paths)

    def assertBuilt(
        self,
        srcpath: str,
        sitepath: str,
        dstpath: str,
        sample: str | bytes | None = None,
    ):
        """
        Check that page at `srcpath` is in the site at `sitepath` and rendered in `dstpath`.

        Optionally check that the rendered content contains `sample`
        """
        return self.mocksite.assertBuilt(srcpath, sitepath, dstpath, sample)
