"""venvstacks layer post-installation script.

* Loads `./share/venv/metadata/venvstacks_layer.json`
* Generates `pyvenv.cfg` for layered environments
* Generates `sitecustomize.py` for layered environments
* Precompiles all Python files in the library folder

This post-installation script is automatically injected when packing environments.
"""

import json
import os

from compileall import compile_dir
from os.path import abspath
from pathlib import Path
from typing import cast, Iterable, NotRequired, Sequence, TypedDict

DEPLOYED_LAYER_CONFIG = "share/venv/metadata/venvstacks_layer.json"


class LayerConfig(TypedDict):
    """Additional details needed to fully configure deployed environments."""

    # fmt: off
    python: str                      # Relative path to this layer's Python executable
    py_version: str                  # Expected X.Y.Z Python version for this environment
    base_python: str                 # Relative path from layer dir to base Python executable
    site_dir: str                    # Relative path to site-packages within this layer
    pylib_dirs: Sequence[str]        # Relative paths to additional sys.path entries
    dynlib_dirs: Sequence[str]       # Relative paths to additional shared library directories
    launch_module: NotRequired[str]  # Module to run with `-m` to launch the application
    # fmt: on

    # All relative paths are relative to the layer folder (and may refer to peer folders)
    # Base runtime layers will have "python" and "base_python" set to the same value
    # Application layers will have "launch_module" set


class ResolvedLayerConfig(TypedDict):
    """LayerConfig with relative paths resolved for a specific layer location."""

    # fmt: off
    layer_path: Path                 # Absolute path to layer environment
    python_path: Path                # Absolute path to this layer's Python executable
    py_version: str                  # Expected X.Y.Z Python version for this environment
    base_python_path: Path           # Absolute path from layer dir to base Python executable
    site_path: Path                  # Absolute path to site-packages within this layer
    pylib_paths: Sequence[Path]      # Absolute paths to additional sys.path entries
    dynlib_paths: Sequence[Path]     # Absolute paths to additional shared library directories
    launch_module: str|None          # Module to run with `-m` to launch the application
    # fmt: on


def load_layer_config(layer_path: Path) -> ResolvedLayerConfig:
    """Read and resolve config for the specified layer environment."""

    def deployed_path(relative_path: str) -> Path:
        """Normalize path and make it absolute, *without* resolving symlinks."""
        absolute_path = Path(abspath(layer_path / relative_path))
        if not absolute_path.is_relative_to(layer_path.parent):
            err_msg = f"Layer path ({str(absolute_path)!r} is not inside {str(layer_path.parent)!r})"
            raise RuntimeError(err_msg)
        return absolute_path

    config_path = layer_path / DEPLOYED_LAYER_CONFIG
    config_text = config_path.read_text(encoding="utf-8")
    # Tolerate runtime errors for incorrectly generated config files
    config = cast(LayerConfig, json.loads(config_text))
    return ResolvedLayerConfig(
        layer_path=layer_path,
        python_path=deployed_path(config["python"]),
        py_version=config["py_version"],
        base_python_path=deployed_path(config["base_python"]),
        site_path=deployed_path(config["site_dir"]),
        pylib_paths=[deployed_path(d) for d in config["pylib_dirs"]],
        dynlib_paths=[deployed_path(d) for d in config["dynlib_dirs"]],
        launch_module=config.get("launch_module", None),
    )


def check_absolute_path(env_path: Path) -> None:
    """Ensures given path is absolute (raises RuntimeError otherwise)."""
    if not env_path.is_absolute():
        err_msg = f"Post-installation must use absolute environment paths ({str(env_path)!r} is relative)"
        raise RuntimeError(err_msg)


def check_absolute_paths(env_paths: Iterable[Path]) -> None:
    """Ensures all given paths are absolute (raises RuntimeError otherwise)."""
    for env_path in env_paths:
        check_absolute_path(env_path)


def generate_pyvenv_cfg(base_python_path: Path, py_version: str) -> str:
    """Generate `pyvenv.cfg` contents for given base Python path and version."""
    check_absolute_path(base_python_path)
    venv_config_lines = [
        f"home = {base_python_path.parent}",
        "include-system-site-packages = false",
        f"version = {py_version}",
        f"executable = {base_python_path}",
        "",
    ]
    return "\n".join(venv_config_lines)


_SITE_CUSTOMIZE_HEADER = '''\
"""venvstacks layered environment site customization script

* Calls `site.addsitedir` for any configured Python path entries
* Calls `os.add_dll_directory` for any configured Windows dynlib paths

This venv module is automatically generated by the post-installation script.
"""

'''


def generate_sitecustomize(
    pylib_paths: Sequence[Path],
    dynlib_paths: Sequence[Path],
    *,
    include_missing_dynlib_paths: bool = False,
) -> str | None:
    """Generate `sitecustomize.py` contents for given linked environment directories."""
    sc_lines = [_SITE_CUSTOMIZE_HEADER]
    if pylib_paths:
        check_absolute_paths(pylib_paths)
        pylib_contents = [
            "# Allow loading modules and packages from linked environments",
            "from site import addsitedir",
        ]
        for path_entry in pylib_paths:
            pylib_contents.append(f"addsitedir({str(path_entry)!r})")
        pylib_contents.append("")
        sc_lines.extend(pylib_contents)
    if dynlib_paths and hasattr(os, "add_dll_directory"):
        # Python packages for Windows generally handle their own dynamic import config
        # This works around the cases that don't
        check_absolute_paths(dynlib_paths)
        dynlib_contents = [
            "# Allow loading misplaced DLLs on Windows",
            "from os import add_dll_directory",
        ]
        for dynlib_path in dynlib_paths:
            if include_missing_dynlib_paths or dynlib_path.exists():
                dynlib_entry = f"add_dll_directory({str(dynlib_path)!r})"
            else:
                # Nothing added DLLs to this folder at build time, so skip it
                # (add_dll_directory fails if the specified folder doesn't exist)
                dynlib_entry = f"# Skipping {str(dynlib_path)!r} (no such directory)"
            dynlib_contents.append(dynlib_entry)
        dynlib_contents.append("")
        sc_lines.extend(dynlib_contents)
    if len(sc_lines) == 1:
        # Environment layer doesn't actually need customizing
        return None
    sc_contents = "\n".join(sc_lines)
    return sc_contents


def _run_postinstall(layer_path: Path) -> None:
    """Run the required post-installation steps in a deployed environment."""
    # Read the layer config file
    config = load_layer_config(layer_path)

    base_python_path = config["base_python_path"]
    env_python_path = config["python_path"]
    if base_python_path != env_python_path:
        # Generate `pyvenv.cfg` for layered environments
        # (path to base python must be emitted as an absolute path)
        venv_config = generate_pyvenv_cfg(base_python_path, config["py_version"])
        venv_config_path = layer_path / "pyvenv.cfg"
        venv_config_path.write_text(venv_config, encoding="utf-8")

        # Generate `sitecustomize.py` for layered environments
        # (avoids having to resolve relative paths on every startup)
        dynlib_paths = config["dynlib_paths"]
        sc_contents = generate_sitecustomize(config["pylib_paths"], dynlib_paths)
        if sc_contents is not None:
            sc_path = config["site_path"] / "sitecustomize.py"
            sc_path.write_text(sc_contents, encoding="utf-8")

    # Precompile Python library modules
    pylib_path = (
        layer_path / "lib"
    )  # "Lib" on Windows, but Windows is not case sensitive
    compile_dir(pylib_path, optimize=0, quiet=True)


if __name__ == "__main__":
    # Actually executing the post-installation step in a deployed environment
    _run_postinstall(Path(__file__).parent)
