import os
import re
import selectors
import shutil
import subprocess
import sys
import time
import weakref
from pathlib import Path
from textwrap import dedent

import pytest

PACKAGE_FILES = ("setup.py", "setup.cfg", "lektor_tailwind.py", "README.md", "LICENSE")


@pytest.fixture
def tmp_project_path(tmp_path, monkeypatch):
    """Copy the example test project to a temporary location.

    The source files for the lektor_tailwind plugin are copied into the
    the ``packages`` directory of the temporary project.

    """
    testdir = Path(__file__).parent
    example = testdir / "example"
    project = tmp_path / "project"

    shutil.copytree(example, project)

    # Remove cruft generated by run_test.sh, just in case
    for fn in "_build", "packages":
        shutil.rmtree(project / fn, ignore_errors=True)

    monkeypatch.setenv("LEKTOR_PROJECT", os.fspath(project))

    return project


@pytest.fixture
def output_css_path(tmp_project_path):
    """Where to expect the built CSS."""
    return tmp_project_path / "_build/static/style.css"


@pytest.fixture
def slow_build(tmp_project_path):
    """Delay Lektor server's build_all."""
    build_delay = 0.5

    plugin = tmp_project_path / "packages/slow_build"
    plugin.mkdir(parents=True)
    plugin.joinpath("setup.py").write_text(
        dedent(
            """
        from setuptools import setup
        setup(
            name="lektor-slow-build",
            entry_points={
                "lektor.plugins": [
                     "slow-build = lektor_slow_build:SlowBuildPlugin",
                ],
            },
        )
    """
        )
    )
    plugin.joinpath("lektor_slow_build.py").write_text(
        dedent(
            f"""
        import time
        from lektor.pluginsystem import Plugin

        class SlowBuildPlugin(Plugin):
            def on_before_build_all(self, *args, **kwargs):
                time.sleep({build_delay:f})
    """
        )
    )


class LektorServerFixture:
    """Run and manage a ``lektor server`` subprocess."""

    def __init__(self, port=9527):
        proc = subprocess.Popen(
            ("lektor", "server", "-p", f"{port:d}"),
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            bufsize=0,
        )

        self.stdout_buf = self._Buffer()
        self.stderr_buf = self._Buffer()
        self.sel = selectors.DefaultSelector()
        self.sel.register(proc.stdout, selectors.EVENT_READ, self.stdout_buf)
        self.sel.register(proc.stderr, selectors.EVENT_READ, self.stderr_buf)

        def _close():
            try:
                self.sel.close()
                proc.terminate()
                proc.wait(5)
            except (subprocess.TimeoutExpired, OSError):
                proc.kill()

        self.close = weakref.finalize(self, _close)

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.close()

    FINISHED_PRUNE = rb"(?m)^Finished prune in "
    TAILWIND_DONE = rb"(?m)^Done in "

    def wait_for_build(self, timeout=30):
        """Wait until both Lektor and Tailwind have completed a build.

        The output from the Lektor server process is scanned until indication
        is seen that both Lektor and Tailwind have completed a build.
        """
        stdout = self.stdout_buf
        stderr = self.stderr_buf
        try_until = time.time() + timeout

        while not (
            stdout.contains(self.FINISHED_PRUNE) and stderr.contains(self.TAILWIND_DONE)
        ):
            time_left = try_until - time.time()
            if time_left <= 0:
                raise AssertionError("timeout waiting for Lektor server")
            self._communicate1(time_left)

        stdout.discard_one(self.FINISHED_PRUNE)
        stderr.discard_one(self.TAILWIND_DONE)

    class _Buffer:
        def __init__(self):
            self.buf = b""

        def contains(self, regexp):
            return re.search(regexp, self.buf) is not None

        def discard_one(self, regexp):
            m = re.search(regexp, self.buf)
            if m is not None:
                self.buf = self.buf[m.end() :]

    def _communicate1(self, timeout):
        """Append any available output to the appropriate buffer."""
        for key, _mask in self.sel.select(timeout):
            rbuf = key.fileobj.read(10_000)
            key.data.buf += rbuf
            sys.stdout.buffer.write(rbuf)  # copy to stdout for diagnostics


@pytest.fixture
def node_env():
    return "development"


@pytest.fixture
def lektor_server(tmp_project_path, node_env, monkeypatch):
    monkeypatch.setenv("NODE_ENV", node_env)
    with LektorServerFixture() as fixture:
        yield fixture


@pytest.mark.parametrize("prune", ("--prune", "--no-prune"))
def test_build_builds_css(tmp_project_path, prune, output_css_path):
    subprocess.run(("lektor", "build", prune), check=True, timeout=30)
    # Check for utility class name rather than just "tailwindcss"
    # to confirm that tailwind actually found the project source files.
    assert ".flex" in output_css_path.read_text()


def test_server_rebuilds_css_when_template_updated(
    lektor_server, tmp_project_path, output_css_path
):
    lektor_server.wait_for_build()
    assert ".flex" in output_css_path.read_text()

    # Give the server a chance to pick up the changes before we start writing
    time.sleep(1)
    with open(tmp_project_path / "templates/layout.html", "a") as fp:
        fp.write("""<div class="before:content-['SENTINEL']"></div>\n""")

    lektor_server.wait_for_build()
    assert "SENTINEL" in output_css_path.read_text()


@pytest.mark.usefixtures("slow_build")  # excercise race condition
def test_server_rebuilds_css_when_input_css_updated(
    lektor_server, tmp_project_path, output_css_path
):
    lektor_server.wait_for_build()
    assert ".flex" in output_css_path.read_text()

    # Give the server a chance to pick up the changes before we start writing
    time.sleep(1)
    with open(tmp_project_path / "assets/static/style.css", "a") as fp:
        fp.write(".SENTINEL { color: red }\n")

    lektor_server.wait_for_build()
    assert ".SENTINEL" in output_css_path.read_text()
    assert ".flex" in output_css_path.read_text()


@pytest.mark.parametrize("node_env", ["production"])
def test_server_build_minified(lektor_server, output_css_path, node_env):
    lektor_server.wait_for_build()
    assert ".flex" in output_css_path.read_text()
    lines_of_css = len(output_css_path.read_text().splitlines())
    assert lines_of_css == 2
