import hashlib
import re
from functools import lru_cache
from pathlib import Path

from pathspec import PathSpec

from .. import constants as cs

_PROJECT_NAME_INVALID_CHARS = re.compile(r"[^A-Za-z0-9_-]+")
_PROJECT_NAME_DIGEST_LEN = 8
_PROJECT_NAME_FALLBACK_BASE = "repo"


def derive_project_name(repo_path: Path) -> str:
    resolved = repo_path.resolve()
    digest = hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()[
        :_PROJECT_NAME_DIGEST_LEN
    ]
    base = _PROJECT_NAME_INVALID_CHARS.sub("_", resolved.name).strip("_")
    if not base:
        base = _PROJECT_NAME_FALLBACK_BASE
    return f"{base}__{digest}"


def resolve_repo_path(repo_path: str | None, target_default: str) -> Path:
    if repo_path:
        return Path(repo_path).resolve()
    if target_default and target_default != ".":
        return Path(target_default).resolve()
    return Path.cwd().resolve()


@lru_cache(maxsize=4096)
def cached_relative_path(file_path: Path, repo_path: Path) -> Path:
    return file_path.relative_to(repo_path)


@lru_cache(maxsize=4096)
def cached_resolve_posix(file_path: Path) -> str:
    return file_path.resolve().as_posix()


# (H) #495: .cgrignore lines and --exclude values are interpreted with
# (H) .gitignore (gitwildmatch) semantics: bare names match at any depth (as
# (H) before), and globs / anchoring / dir-only trailing slash now work. The
# (H) spec is compiled once per pattern set (frozensets are hashable).
@lru_cache(maxsize=64)
def compiled_ignore_spec(patterns: frozenset[str]) -> PathSpec:
    return PathSpec.from_lines(cs.GITWILDMATCH_STYLE, sorted(patterns))


def matches_ignore_patterns(rel_path_str: str, patterns: frozenset[str]) -> bool:
    return compiled_ignore_spec(patterns).match_file(rel_path_str)


_GLOB_MAGIC = re.compile(r"[*?\[]")


def unignore_could_match_within(pattern: str, rel_dir: str) -> bool:
    # (H) Dir-pruning guard: keep a pruned-by-default directory when an
    # (H) unignore pattern could match it or anything beneath it.
    if "/" not in pattern.rstrip("/"):
        # (H) slash-free patterns are unanchored: they can match at any depth.
        return True
    head, *glob_rest = _GLOB_MAGIC.split(pattern, 1)
    if glob_rest:
        # (H) the glob may complete the trailing segment; keep whole segments.
        head = head.rsplit("/", 1)[0]
    head = head.strip("/")
    return (
        not head
        or head == rel_dir
        or head.startswith(f"{rel_dir}/")
        or rel_dir.startswith(f"{head}/")
    )


def should_skip_path(
    path: Path,
    repo_path: Path,
    exclude_paths: frozenset[str] | None = None,
    unignore_paths: frozenset[str] | None = None,
    is_file: bool | None = None,
) -> bool:
    _is_file = path.is_file() if is_file is None else is_file
    if _is_file and path.suffix in cs.IGNORE_SUFFIXES:
        return True
    rel_path = cached_relative_path(path, repo_path)
    rel_path_str = rel_path.as_posix()
    # (H) a trailing slash marks the path as a directory for dir-only patterns.
    match_path = rel_path_str if _is_file else f"{rel_path_str}/"
    if exclude_paths and matches_ignore_patterns(match_path, exclude_paths):
        return True
    # (H) unignore rescues only built-in ignores, never explicit user excludes.
    if unignore_paths and matches_ignore_patterns(match_path, unignore_paths):
        return False
    if (
        not _is_file
        and unignore_paths
        and any(unignore_could_match_within(u, rel_path_str) for u in unignore_paths)
    ):
        # (H) structure traversal must descend into a built-in-ignored dir when
        # (H) an unignore pattern can match beneath it (mirrors _should_keep_dir),
        # (H) or rescued files get no Folder/Package ancestry in the graph.
        return False
    dir_parts = rel_path.parent.parts if _is_file else rel_path.parts
    return not cs.IGNORE_PATTERNS.isdisjoint(dir_parts)


def should_skip_rel_file(
    rel_path_str: str,
    dir_parts: tuple[str, ...],
    suffix: str,
    exclude_paths: frozenset[str] | None = None,
    unignore_paths: frozenset[str] | None = None,
) -> bool:
    if suffix in cs.IGNORE_SUFFIXES:
        return True
    if exclude_paths and matches_ignore_patterns(rel_path_str, exclude_paths):
        return True
    # (H) unignore rescues only built-in ignores, never explicit user excludes.
    if unignore_paths and matches_ignore_patterns(rel_path_str, unignore_paths):
        return False
    return not cs.IGNORE_PATTERNS.isdisjoint(dir_parts)
