from __future__ import annotations

import hashlib
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional


_HASH_RE = re.compile(
    r"""
    ^
    (?P<stem>.*?)
    \.
    (?P<alg>[a-z0-9]+)_
    (?P<digest>[0-9a-f]+)
    (?P<suffixes>(?:\..+)?)   # original suffix(es), optional
    $
    """,
    re.VERBOSE,
)


@dataclass(frozen=True)
class StampResult:
    source: Path
    algorithm: str
    digest: str
    stamped_name: str
    stamped_path: Path


def _iter_file_chunks(path: Path, chunk_size: int = 1024 * 1024) -> Iterable[bytes]:
    with path.open("rb") as f:
        while True:
            b = f.read(chunk_size)
            if not b:
                break
            yield b


def compute_digest(path: Path, algorithm: str = "sha256") -> str:
    """
    Compute hex digest for a file using the requested algorithm (default: sha256).
    """
    try:
        h = hashlib.new(algorithm)
    except ValueError as e:
        raise ValueError(f"Unsupported hash algorithm: {algorithm!r}") from e

    for chunk in _iter_file_chunks(path):
        h.update(chunk)
    return h.hexdigest()


def make_stamped_filename(path: Path, digest: str, algorithm: str = "sha256") -> str:
    """
    Build a stamped filename:
      <stem>.<alg>_<digest><suffixes>

    Example:
      photo.sha256_deadbeef....jpg
    """
    suffixes = "".join(path.suffixes)
    stem = path.name[: -len(suffixes)] if suffixes else path.name
    return f"{stem}.{algorithm}_{digest}{suffixes}"


def stamp_file(
    file_path: Path,
    *,
    algorithm: str = "sha256",
    out_dir: Optional[Path] = None,
    copy: bool = False,
    rename: bool = False,
    overwrite: bool = False,
) -> StampResult:
    """
    Stamp a file with a hash in its filename.

    - If copy=True, copies to out_dir (or same dir) with stamped name.
    - If rename=True, renames in-place (or into out_dir if provided).
    - If neither copy nor rename, just computes and returns stamped name/path.
    """
    file_path = file_path.expanduser().resolve()

    if not file_path.exists():
        raise FileNotFoundError(str(file_path))
    if not file_path.is_file():
        raise IsADirectoryError(str(file_path))
    if copy and rename:
        raise ValueError("Choose only one of copy=True or rename=True.")

    digest = compute_digest(file_path, algorithm=algorithm)
    stamped_name = make_stamped_filename(file_path, digest=digest, algorithm=algorithm)

    target_dir = (out_dir.expanduser().resolve() if out_dir else file_path.parent)
    target_dir.mkdir(parents=True, exist_ok=True)
    stamped_path = (target_dir / stamped_name).resolve()

    if stamped_path.exists() and not overwrite and (copy or rename):
        raise FileExistsError(f"Target already exists: {stamped_path}")

    if copy:
        shutil.copy2(file_path, stamped_path)
    elif rename:
        # pathlib rename() can move across directories
        file_path.rename(stamped_path)

    return StampResult(
        source=file_path,
        algorithm=algorithm,
        digest=digest,
        stamped_name=stamped_name,
        stamped_path=stamped_path,
    )


def parse_stamped_filename(path: Path) -> Optional[tuple[str, str]]:
    """
    If filename matches <stem>.<alg>_<digest><suffixes>, return (alg, digest).
    Otherwise return None.
    """
    m = _HASH_RE.match(path.name)
    if not m:
        return None
    return (m.group("alg"), m.group("digest"))


def verify_file(file_path: Path) -> bool:
    """
    Verify that the file's name contains a digest that matches the file contents.
    """
    file_path = file_path.expanduser().resolve()

    if not file_path.exists():
        raise FileNotFoundError(str(file_path))
    if not file_path.is_file():
        raise IsADirectoryError(str(file_path))

    parsed = parse_stamped_filename(file_path)
    if not parsed:
        raise ValueError("Filename does not look stamped (expected .<alg>_<digest> in name).")

    alg, expected = parsed
    actual = compute_digest(file_path, algorithm=alg)
    return actual.lower() == expected.lower()
