import json
import logging
import subprocess
from dataclasses import dataclass
from hashlib import sha1
from itertools import chain
from pathlib import Path
from typing import Iterable, Self

import rjsmin
import sass
from django.conf import settings

from api.urls import api
from staticfiles.apps import BUNDLED_FOLDER_NAME, BUNDLED_ROOT, GENERATED_ROOT


@dataclass
class JsBundlerManifestEntry:
    src: str
    out: str

    @classmethod
    def from_json_entry(cls, entry: dict[str, any]) -> list[Self]:
        # We have two parts for a manifest entry
        # The `src` element which is what the user asks django as a static
        # The `out` element which is it's real name in the output static folder

        # For the src part:
        # The manifest file contains the path of the file relative to the project root
        # We want the relative path of the file inside their respective static folder
        # because that's what the user types when importing statics and that's what django gives us
        # This is really similar to what we are doing in the bundler, it uses a similar algorithm
        # Example:
        # core/static/bundled/alpine-index.ts -> bundled/alpine-index.ts
        # core/static/bundled/components/include-index.ts -> core/static/bundled/components/include-index.ts
        def get_relative_src_name(name: str) -> str:
            original_path = Path(name)
            relative_path: list[str] = []
            for directory in reversed(original_path.parts):
                relative_path.append(directory)
                # Contrary to the bundler algorithm, we do want to keep the bundled prefix
                if directory == BUNDLED_FOLDER_NAME:
                    break
            return str(Path(*reversed(relative_path)))

        # For the out part:
        # The bundler is configured to output files in generated/bundled and considers this folders as it's root
        # Thus, the output name doesn't contain the `bundled` prefix that we need, we add it ourselves
        ret = [
            cls(
                src=get_relative_src_name(entry["src"]),
                out=str(Path(BUNDLED_FOLDER_NAME) / entry["file"]),
            )
        ]

        def remove_hash(path: Path) -> str:
            # Hashes are configured to be surrounded by `.`
            # Filenames are like this path/to/file.hash.ext
            unhashed = ".".join(path.stem.split(".")[:-1])
            return str(path.with_stem(unhashed))

        # CSS files generated by entrypoints don't have their own entry in the manifest
        # They are however listed as an attribute of the entry point that generates them
        # Their listed name is the one that has been generated inside the generated/bundled folder
        # We prefix it with `bundled` and then generate an `src` name by removing the hash
        for css in entry.get("css", []):
            path = Path(BUNDLED_FOLDER_NAME) / css
            ret.append(
                cls(
                    src=remove_hash(path),
                    out=str(path),
                )
            )
        return ret


class JSBundlerManifest:
    def __init__(self, manifest: Path):
        with open(manifest, "r") as f:
            self._manifest = json.load(f)

        self._files = chain(
            *[
                JsBundlerManifestEntry.from_json_entry(value)
                for value in self._manifest.values()
                if value.get("isEntry", False)
            ]
        )
        self.mapping = {file.src: file.out for file in self._files}


class JSBundler:
    @staticmethod
    def compile():
        """Bundle js files with the javascript bundler for production."""
        process = subprocess.Popen(["npm", "run", "compile"])
        _ = process.wait()
        if process.returncode:
            raise RuntimeError(f"Bundler failed with returncode {process.returncode}")

    @staticmethod
    def get_manifest() -> JSBundlerManifest:
        return JSBundlerManifest(BUNDLED_ROOT / ".vite" / "manifest.json")

    @staticmethod
    def is_in_bundle(name: str | None) -> bool:
        if name is None:
            return False
        return Path(name).parts[0] == BUNDLED_FOLDER_NAME


class Scss:
    @dataclass
    class CompileArg:
        absolute: Path  # Absolute path to the file
        relative: Path  # Relative path inside the folder it has been collected

    @staticmethod
    def compile(files: CompileArg | Iterable[CompileArg]):
        """Compile scss files to css files."""
        # Generate files inside the generated folder
        # .css files respects the hierarchy in the static folder it was found
        # This converts arg.absolute -> generated/{arg.relative}.scss
        # Example:
        #   app/static/foo.scss          -> generated/foo.css
        #   app/static/bar/foo.scss      -> generated/bar/foo.css
        #   custom/location/bar/foo.scss -> generated/bar/foo.css
        if isinstance(files, Scss.CompileArg):
            files = [files]

        base_args = {"output_style": "compressed", "precision": settings.SASS_PRECISION}

        compiled_files = {
            file.relative.with_suffix(".css"): sass.compile(
                filename=str(file.absolute), **base_args
            )
            for file in files
        }
        for file, content in compiled_files.items():
            dest = GENERATED_ROOT / file
            dest.parent.mkdir(exist_ok=True, parents=True)
            dest.write_text(content)


class JS:
    @staticmethod
    def minify():
        to_exec = [
            p
            for p in settings.STATIC_ROOT.rglob("*.js")
            if ".min" not in p.suffixes
            and (settings.STATIC_ROOT / BUNDLED_FOLDER_NAME) not in p.parents
        ]
        for path in to_exec:
            p = path.resolve()
            minified = rjsmin.jsmin(p.read_text())
            p.write_text(minified)
            logging.getLogger("main").info(f"Minified {path}")


class OpenApi:
    OPENAPI_DIR = GENERATED_ROOT / "openapi"

    @classmethod
    def compile(cls) -> subprocess.Popen[bytes] | None:
        """Compile a TS client for the sith API. Only generates it if it changed."""
        logging.getLogger("django").info("Compiling open api typescript client")
        out = cls.OPENAPI_DIR / "schema.json"
        cls.OPENAPI_DIR.mkdir(parents=True, exist_ok=True)

        old_hash = ""
        if out.exists():
            with open(out, "rb") as f:
                old_hash = sha1(f.read()).hexdigest()

        schema = api.get_openapi_schema()
        # Remove hash from operationIds
        # This is done for cache invalidation but this is too aggressive
        for path in schema["paths"].values():
            for action, desc in path.items():
                path[action]["operationId"] = "_".join(
                    desc["operationId"].split("_")[:-1]
                )
        schema = str(schema)

        if old_hash == sha1(schema.encode("utf-8")).hexdigest():
            logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
            return

        with open(out, "w") as f:
            _ = f.write(schema)

        return subprocess.Popen(["npm", "run", "openapi"])
