from __future__ import annotations

import contextlib
import hashlib
import inspect
import os
from datetime import datetime, timedelta
from pathlib import Path
from time import time
from typing import TYPE_CHECKING, Generator

if TYPE_CHECKING:
    from .task import Task


PathOrStr = Path | str


def evaluate_digest(path: PathOrStr, size=2**16, hasher: str = "sha1") -> bytes:
    hasher_instance = hashlib.new(hasher)
    path = Path(path)
    with path.open("rb") as fp:
        while chunk := fp.read(size):
            hasher_instance.update(chunk)
    return hasher_instance.digest()


def evaluate_hexdigest(path: PathOrStr, size=2**16, hasher: str = "sha1") -> str:
    return evaluate_digest(path, size, hasher).hex()


class Timer:
    def __init__(self):
        self.start = None
        self.end = None

    def __enter__(self) -> Timer:
        self.start = time()
        return self

    def __exit__(self, *_) -> None:
        self.end = time()

    @property
    def duration(self):
        assert self.start is not None, "Timer has not started yet."
        assert self.end is not None, "Timer has not finished yet."
        return self.end - self.start


class CookError(Exception):
    pass


class FailedTaskError(Exception):
    def __init__(self, *args: object, task: "Task") -> None:
        super().__init__(*args)
        self.task = task


@contextlib.contextmanager
def working_directory(path: PathOrStr) -> Generator[Path]:
    path = Path(path)
    original = Path.cwd()
    try:
        os.chdir(path)
        yield path
    finally:
        os.chdir(original)


def get_location() -> tuple[Path, int]:
    """
    Get the first location in the call stack which is not part of the Cook package.

    Returns:
        Location as a tuple :code:`(filename, lineno)`.
    """
    frame = inspect.currentframe()
    assert frame is not None, "Could not fetch current frame."
    while frame.f_globals.get("__name__", "<unknown>").startswith("cook"):
        frame = frame.f_back
        assert frame is not None, "Could not fetch parent frame."
    return Path(frame.f_code.co_filename).resolve(), frame.f_lineno


def format_timedelta(delta: timedelta) -> str:
    """
    Format a time difference.
    """
    if delta.total_seconds() < 1:
        return str(delta)
    return str(delta).rsplit(".", 2)[0]


def format_datetime(dt: datetime) -> str:
    """
    Format a date-time.
    """
    return str(dt).rsplit(".", 2)[0]


def dict2args(*positional, flags: dict | None = None, **kwargs) -> list[str]:
    """
    Convert arguments to command-line argument strings.

    Args:
        *positional: Positional arguments appended after flags.
        flags: Dictionary for flag names that aren't valid Python identifiers
            (e.g., names containing dashes).
        **kwargs: Keyword arguments converted to flags.

    Returns:
        List of command-line argument strings.

    Example:

        .. doctest::

            >>> from cook.util import dict2args
            >>> dict2args(output="file.txt", verbose=True, count=3)
            ['--output=file.txt', '--verbose', '--count=3']
            >>> dict2args("input.txt", "output.txt", verbose=True)
            ['--verbose', 'input.txt', 'output.txt']
            >>> dict2args(flags={"dry-run": True, "num-workers": 4})
            ['--dry-run', '--num-workers=4']
            >>> dict2args(values=[1, 2, 3])
            ['--values=1,2,3']
    """
    if flags is not None:
        kwargs = flags | kwargs
    args = []
    for key, value in kwargs.items():
        if isinstance(value, bool):
            if value:
                args.append(f"--{key}")
        elif isinstance(value, (list, tuple)):
            if not value:
                raise ValueError(f"empty sequence for argument {key!r}")
            args.append(f"--{key}={','.join(map(str, value))}")
        else:
            args.append(f"--{key}={value}")
    args.extend(str(arg) for arg in positional)
    return args
