import functools
from pathlib import Path
from subprocess import PIPE
from tempfile import TemporaryDirectory
from typing import Iterable


try:
    import tomllib
except ImportError:
    import tomli as tomllib
import toolz
from attr import (
    field,
    frozen,
)
from attr.validators import (
    deep_iterable,
    instance_of,
    optional,
)
from packaging.specifiers import SpecifierSet
from packaging.version import Version

from xorq.common.utils.process_utils import (
    Popened,
    in_nix_shell,
)
from xorq.common.utils.tar_utils import (
    TGZAppender,
    TGZProxy,
    calc_tgz_content_hexdigest,
    copy_path,
)


REQUIREMENTS_NAME = "requirements.txt"
PYPROJECT_NAME = "pyproject.toml"
BUILD_SDIST_NAME = "sdist.tar.gz"


@frozen
class Sdister:
    project_path = field(validator=instance_of(Path), converter=Path)

    def __attrs_post_init__(self):
        assert self.project_path.exists()
        assert self.pyproject_path.exists()

    @property
    def pyproject_path(self):
        return self.project_path.joinpath(PYPROJECT_NAME)

    @property
    @functools.cache
    def _tmpdir(self):
        return TemporaryDirectory()

    @property
    def tmpdir(self):
        return Path(self._tmpdir.name)

    @property
    @functools.cache
    def _uv_build_popened(self):
        args = (
            "uv",
            "build",
            "--sdist",
            "--out-dir",
            str(self.tmpdir),
            str(self.pyproject_path.parent),
        )
        popened = Popened(args)
        return popened

    popened = _uv_build_popened

    @property
    def _sdist_path(self):
        prefix = "Successfully built "
        (_, line) = self._uv_build_popened.stderr.strip().rsplit("\n", 1)
        (first, rest) = (line[: len(prefix)], line[len(prefix) :])
        assert first == prefix
        sdist_path = Path(rest)
        return sdist_path

    def ensure_requirements_member(self):
        sdist_path = self._sdist_path
        if not TGZProxy(sdist_path).toplevel_name_exists(REQUIREMENTS_NAME):
            requirements_path = self.tmpdir.joinpath(REQUIREMENTS_NAME)
            requirements_text = uv_tool_run_uv_pip_freeze_package_path(sdist_path)
            requirements_path.write_text(requirements_text)
            TGZAppender.append_toplevel(sdist_path, requirements_path)

    @property
    @functools.cache
    def sdist_path(self):
        self.ensure_requirements_member()
        return self._sdist_path

    @property
    def sdist_path_hexdigest(self):
        return calc_tgz_content_hexdigest(self.sdist_path)

    @classmethod
    def from_script_path(cls, script_path):
        pyproject_path = find_file_upwards(script_path, PYPROJECT_NAME)
        return cls(pyproject_path.parent)


@frozen
class SdistBuilder:
    script_path = field(validator=instance_of(Path), converter=Path)
    sdist_path = field(validator=instance_of(Path), converter=Path)
    args = field(
        validator=deep_iterable(instance_of(str), instance_of(tuple)), default=()
    )
    maybe_packager = field(
        validator=optional(instance_of(Path)),
        converter=toolz.curried.excepts(Exception, Path),
        default=None,
    )
    require_requirements = field(validator=instance_of(bool), default=True)

    def __attrs_post_init__(self):
        if self.require_requirements:
            assert TGZProxy(self.sdist_path).toplevel_name_exists(REQUIREMENTS_NAME)
        self.ensure_requirements_path()
        assert self.requirements_path.exists()

    @property
    @functools.cache
    def _tmpdir(self):
        return TemporaryDirectory()

    @property
    def tmpdir(self):
        return Path(self._tmpdir.name)

    @property
    def requirements_path(self):
        return self.tmpdir.joinpath(REQUIREMENTS_NAME)

    @property
    @functools.cache
    def untgzed_path(self):
        tp = TGZProxy(self.sdist_path)
        untgzed_path = tp.extract_toplevel(self.tmpdir)
        return untgzed_path

    def ensure_requirements_path(self):
        if not self.requirements_path.exists():
            if TGZProxy(self.sdist_path).toplevel_name_exists(REQUIREMENTS_NAME):
                TGZProxy(self.sdist_path).extract_toplevel_name(
                    REQUIREMENTS_NAME, self.requirements_path
                )
            else:
                requirements_text = uv_tool_run_uv_pip_freeze_package_path(
                    self.sdist_path
                )
                self.requirements_path.write_text(requirements_text)

    @property
    @functools.cache
    def _uv_tool_run_xorq_build(self):
        args = self.args or ("xorq", "build", str(self.script_path))
        popened = uv_tool_run(
            *args,
            with_=str(self.untgzed_path),
        )
        return popened

    popened = _uv_tool_run_xorq_build

    def get_build_path(self):
        # FIXME: don't capture stdout so user can still use --pdb
        return Path(self._uv_tool_run_xorq_build.stdout.strip())

    @functools.cache
    def copy_sdist(self):
        target = self.get_build_path().joinpath(BUILD_SDIST_NAME)
        copy_path(self.sdist_path, target)
        return target

    @property
    def build_path(self):
        self.copy_sdist()
        return self.get_build_path()

    @classmethod
    def from_script_path(cls, script_path, project_path=None, **kwargs):
        packager = (
            Sdister(project_path)
            if project_path
            else Sdister.from_script_path(script_path)
        )
        return cls(
            script_path=script_path,
            sdist_path=packager.sdist_path,
            maybe_packager=packager,
            **kwargs,
        )


@frozen
class SdistRunner:
    build_path = field(validator=instance_of(Path), converter=Path)
    args = field(
        validator=deep_iterable(instance_of(str), instance_of(tuple)), default=()
    )

    def __attrs_post_init__(self):
        assert self.build_path.exists()
        assert self.sdist_path.exists()
        # we ALWAYS require requirements.txt to run
        assert TGZProxy(self.sdist_path).toplevel_name_exists(REQUIREMENTS_NAME)

    @property
    def sdist_path(self):
        return self.build_path.joinpath(BUILD_SDIST_NAME)

    @property
    @functools.cache
    def _tmpdir(self):
        return TemporaryDirectory()

    @property
    def tmpdir(self):
        return Path(self._tmpdir.name)

    @property
    def requirements_path(self):
        return self.tmpdir.joinpath(REQUIREMENTS_NAME)

    def ensure_requirements_path(self):
        if not self.requirements_path.exists():
            if TGZProxy(self.sdist_path).toplevel_name_exists(REQUIREMENTS_NAME):
                TGZProxy(self.sdist_path).extract_toplevel_name(
                    REQUIREMENTS_NAME, self.requirements_path
                )
            else:
                requirements_text = uv_tool_run_uv_pip_freeze_package_path(
                    self.sdist_path
                )
                self.requirements_path.write_text(requirements_text)

    @property
    @functools.cache
    def _uv_tool_run_xorq_run(self):
        self.ensure_requirements_path()
        args = self.args or ("xorq", "run", str(self.build_path))
        # FIXME: enable streaming output
        popened = uv_tool_run(
            *args,
            with_=self.sdist_path,
            with_requirements=self.requirements_path,
            capturing=False,
        )
        return popened

    popened = _uv_tool_run_xorq_run


def find_file_upwards(start, name):
    path = Path(start).absolute()
    if path.is_file():
        path = path.parent
    paths = (p.joinpath(name) for p in (path, *path.parents))
    found = next((p for p in paths if p.exists()), None)
    if not found:
        raise ValueError(
            f"could not find {name!r} in {start!r} or any parent directory"
        )
    return found


def uv_tool_run(
    *args,
    isolated=True,
    python_version=None,
    with_=None,
    with_requirements=None,
    check=True,
    capturing=True,
):
    command_v_xorq = Popened.check_output("command -v xorq", shell=True).strip()
    args = tuple(el if el != command_v_xorq else "xorq" for el in args)
    python_version = python_version or (
        get_acceptable_python_versions(with_)[-1] if with_ else None
    )
    popened_args = (
        "uv",
        "tool",
        "run",
        *(("--python", python_version) if python_version else ()),
        *(("--isolated",) if isolated else ()),
        *(("--with", str(with_)) if with_ else ()),
        *(("--with-requirements", str(with_requirements)) if with_requirements else ()),
        *args,
    )
    kwargs_tuple = (
        (("stdout", PIPE), ("stderr", PIPE))
        if capturing
        else (("stdout", None), ("stderr", None))
    )
    if in_nix_shell():
        import os

        env = os.environ | {
            "LD_LIBRARY_PATH": os.environ["UV_TOOL_RUN_LD_LIBRARY_PATH"]
        }
        kwargs_tuple = kwargs_tuple + (("env", env),)
    popened = Popened(popened_args, kwargs_tuple=kwargs_tuple)
    if check:
        popened.wait()
        assert not popened.returncode, popened.stderr
    return popened


def get_uv_python_version(directory):
    if not Path(directory).is_dir():
        td = TemporaryDirectory()
        TGZProxy(directory).extract_toplevel(td.name)
        directory = str(td.name)
    cmd = "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
    args = f'uv run --directory {str(directory)} python -c "{cmd}"'
    python_version = Popened.check_output(args)
    return python_version.strip()


def get_acceptable_python_versions(
    path: str | Path,
    known_minors: Iterable[int] = range(8, 14),
) -> tuple[Version, ...]:
    if (path := Path(path)).name == PYPROJECT_NAME:
        pass
    elif path.is_dir() and path.joinpath(PYPROJECT_NAME).exists():
        path = path.joinpath(PYPROJECT_NAME)
    elif path.suffixes[-2:] == [".tar", ".gz"]:
        td = TemporaryDirectory()
        path = TGZProxy(path).extract_toplevel_name(
            PYPROJECT_NAME, Path(td.name, PYPROJECT_NAME)
        )
    else:
        raise ValueError(
            f"can only handle {PYPROJECT_NAME} or {PYPROJECT_NAME} containing dir / .tar.gz"
        )
    data = tomllib.loads(Path(path).read_text())
    requires_python = toolz.get_in(("project", "requires-python"), data)
    spec = SpecifierSet(requires_python)
    acceptable_python_versions = tuple(
        str(v) for v in (Version(f"3.{minor}") for minor in known_minors) if v in spec
    )
    if not acceptable_python_versions:
        raise ValueError("No acceptable python versions found")
    return acceptable_python_versions


def uv_tool_run_uv_pip_freeze(sdist_path):
    # don't use "uv pip freeze": causes issues with nix and enclosing venv
    popened = uv_tool_run("pip", "freeze", with_=sdist_path)
    stdout = popened.stdout
    return stdout


def uv_tool_run_uv_pip_freeze_package_path(package_path):
    stdout = uv_tool_run_uv_pip_freeze(package_path)
    splat = stdout.split("\n")
    filtered = (el for el in splat if "file:///" not in el)
    joined = "\n".join(filtered)
    return joined
