"""File I/O utilities for git-stage-batch."""

from __future__ import annotations

import hashlib
import io
import os
import stat
from collections.abc import Collection
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Protocol

if TYPE_CHECKING:
    from typing_extensions import Buffer

from .atomic_write import (
    fsync_directory as _fsync_directory,
    write_chunks_atomically,
)
from ..exceptions import CommandError
from ..git_paths import decode_path, display_path, encode_path, nul_records
from ..i18n import _


class AtomicWriteModePolicy(Enum):
    """Permission policy for an atomically replaced file."""

    PRIVATE = "private"
    PRESERVE_EXISTING = "preserve-existing"
    CALLER_SUPPLIED = "caller-supplied"


PRIVATE_FILE_MODE = 0o600
PROJECT_FILE_MODE = 0o644
_PATH_LIST_MAGIC = b"\0git-stage-batch-path-list-v1\0"


class _Digest(Protocol):
    """Incremental digest operations used by the raw reader."""

    def update(self, data: Buffer) -> None: ...

    def hexdigest(self) -> str: ...


class _DigestingRawReader(io.RawIOBase):
    """Hash the exact bytes consumed by a buffered text reader."""

    def __init__(self, source: io.FileIO, digest: _Digest) -> None:
        self._source = source
        self._digest = digest

    def readable(self) -> bool:
        return True

    def readinto(self, buffer: Buffer) -> int | None:
        count = self._source.readinto(buffer)
        if count:
            self._digest.update(memoryview(buffer)[:count])
        return count


def read_required_text_file_contents(path: Path) -> str:
    """Read a required file's text contents with UTF-8 encoding.

    Unlike :func:`read_text_file_contents`, absence is not treated as empty
    content. Callers that require durable state can therefore distinguish a
    missing file from a present, zero-length file.
    """
    return path.read_text(encoding="utf-8", errors="surrogateescape")


def read_required_text_file_contents_and_sha256(path: Path) -> tuple[str, str]:
    """Read and hash one regular-file stream without retaining a byte copy."""
    flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
    flags |= getattr(os, "O_NOFOLLOW", 0)
    flags |= getattr(os, "O_NONBLOCK", 0)
    opened_descriptor = os.open(path, flags)
    file_descriptor: int | None = opened_descriptor
    try:
        metadata = os.fstat(opened_descriptor)
        if not stat.S_ISREG(metadata.st_mode):
            raise OSError(f"Path is not a regular file: {path}")
        source = io.FileIO(opened_descriptor, mode="rb", closefd=True)
        file_descriptor = None
        digest = hashlib.sha256()
        with source:
            raw_reader = _DigestingRawReader(source, digest)
            with io.TextIOWrapper(
                io.BufferedReader(raw_reader),
                encoding="utf-8",
                errors="surrogateescape",
            ) as text_reader:
                contents = text_reader.read()
        return contents, digest.hexdigest()
    finally:
        if file_descriptor is not None:
            os.close(file_descriptor)


def read_text_file_contents(path: Path) -> str:
    """Read an optional file's text contents with UTF-8 encoding.

    Args:
        path: Path to the file to read

    Returns:
        File contents as string, or empty string if file doesn't exist
    """
    try:
        return read_required_text_file_contents(path)
    except FileNotFoundError:
        return ""


def write_text_file_contents(
    path: Path,
    data: str,
    *,
    mode_policy: AtomicWriteModePolicy = AtomicWriteModePolicy.PRIVATE,
    mode: int | None = None,
) -> None:
    """Atomically write text, creating parent directories as needed.

    Args:
        path: Path to the file to write
        data: Text content to write
    """
    _write_file_contents_atomically(
        path,
        data.encode("utf-8", errors="surrogateescape"),
        mode_policy=mode_policy,
        mode=mode,
    )


def stream_text_file_lines(path: Path) -> Iterable[str]:
    """Yield a text file's lines, or no lines if it does not exist."""
    if not path.exists():
        return
    with path.open("r", encoding="utf-8", errors="surrogateescape") as file_handle:
        yield from file_handle


def stream_nonblank_text_file_lines(path: Path) -> Iterable[str]:
    """Yield stripped nonblank lines from a text file."""
    for line in stream_text_file_lines(path):
        stripped = line.strip()
        if stripped:
            yield stripped


def read_text_file_line_set(path: Path) -> set[str]:
    """Read stripped nonblank text lines into a set."""
    return set(stream_nonblank_text_file_lines(path))


def count_nonblank_text_file_lines(path: Path) -> int:
    """Count nonblank text lines without reading the whole file."""
    return sum(1 for _line in stream_nonblank_text_file_lines(path))


def write_file_bytes(
    path: Path,
    data: bytes,
    *,
    mode_policy: AtomicWriteModePolicy = AtomicWriteModePolicy.PRIVATE,
    mode: int | None = None,
) -> None:
    """Atomically write raw bytes, creating parent directories as needed."""
    _write_file_contents_atomically(
        path,
        data,
        mode_policy=mode_policy,
        mode=mode,
    )


def write_file_byte_chunks_atomically(
    path: Path,
    chunks: Iterable[bytes],
    *,
    mode_policy: AtomicWriteModePolicy = AtomicWriteModePolicy.PRIVATE,
    mode: int | None = None,
) -> None:
    """Atomically stream byte chunks into one regular file."""
    metadata = _existing_file_metadata(path)
    replacement_mode = _replacement_mode(metadata, mode_policy, mode)
    write_chunks_atomically(
        path,
        chunks,
        mode=replacement_mode,
        existing_metadata=metadata,
    )


def _existing_file_metadata(path: Path) -> os.stat_result | None:
    try:
        metadata = path.lstat()
    except FileNotFoundError:
        return None
    if stat.S_ISLNK(metadata.st_mode):
        raise CommandError(
            _(
                "Refusing to replace symlink '{path}' with a regular file. "
                "Remove the symlink or update its target explicitly."
            ).format(path=display_path(str(path)))
        )
    return metadata


def _replacement_mode(
    metadata: os.stat_result | None,
    mode_policy: AtomicWriteModePolicy,
    mode: int | None,
) -> int:
    if mode_policy is AtomicWriteModePolicy.PRIVATE:
        if mode is not None:
            raise ValueError(
                "private atomic writes do not accept a caller-supplied mode"
            )
        return PRIVATE_FILE_MODE
    if mode_policy is AtomicWriteModePolicy.PRESERVE_EXISTING:
        if metadata is not None:
            return stat.S_IMODE(metadata.st_mode)
        return PROJECT_FILE_MODE if mode is None else mode
    if mode_policy is AtomicWriteModePolicy.CALLER_SUPPLIED:
        if mode is None:
            raise ValueError("caller-supplied atomic writes require a mode")
        return mode
    raise ValueError(f"unsupported atomic write mode policy: {mode_policy!r}")


def _write_file_contents_atomically(
    path: Path,
    data: bytes,
    *,
    mode_policy: AtomicWriteModePolicy,
    mode: int | None,
) -> None:
    """Replace one state file without exposing partial contents."""
    metadata = _existing_file_metadata(path)
    replacement_mode = _replacement_mode(metadata, mode_policy, mode)
    write_chunks_atomically(
        path,
        (data,),
        mode=replacement_mode,
        existing_metadata=metadata,
    )


def path_is_empty(path: Path) -> bool:
    """Return whether a file contains no bytes."""
    with path.open("rb") as file_handle:
        while chunk := file_handle.read(1024 * 1024):
            if chunk:
                return False
    return True


def append_lines_to_file(path: Path, lines: Iterable[str]) -> None:
    """Append lines to a file, creating parent directories as needed.

    Each line is normalized to end with a single newline character.

    Args:
        path: Path to the file
        lines: Lines to append (newlines will be normalized)
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    file_descriptor = os.open(
        path,
        os.O_WRONLY | os.O_APPEND | os.O_CREAT,
        PRIVATE_FILE_MODE,
    )
    os.fchmod(file_descriptor, PRIVATE_FILE_MODE)
    with os.fdopen(
        file_descriptor,
        "a",
        encoding="utf-8",
        errors="surrogateescape",
    ) as file_handle:
        for line in lines:
            file_handle.write(str(line).rstrip() + "\n")
        file_handle.flush()
        os.fsync(file_handle.fileno())
    _fsync_directory(path.parent)


def read_file_paths_file(path: Path) -> list[str]:
    """Read a file containing one path per line, returning a deduplicated sorted list.

    Args:
        path: Path to file containing paths

    Returns:
        Sorted list of unique paths
    """
    if not path.exists():
        return []

    contents = path.read_bytes()
    if contents.startswith(_PATH_LIST_MAGIC):
        encoded_paths = nul_records(contents[len(_PATH_LIST_MAGIC) :])
        return sorted({decode_path(encoded_path) for encoded_path in encoded_paths})

    return sorted(read_text_file_line_set(path))


def _path_requires_lossless_list_encoding(path: str) -> bool:
    return "\n" in path or "\r" in path or path != path.strip()


def write_file_paths_file(path: Path, file_paths: Iterable[str]) -> None:
    """Write file paths to a file, one per line, sorted and deduplicated.

    Args:
        path: Path to file to write
        file_paths: Paths to write
    """
    unique_paths = sorted(set(file_paths))
    if any(_path_requires_lossless_list_encoding(path) for path in unique_paths):
        contents = _PATH_LIST_MAGIC + b"".join(
            encode_path(path) + b"\0" for path in unique_paths
        )
        write_file_bytes(path, contents)
        return

    content = "\n".join(unique_paths)
    if unique_paths:
        content += "\n"
    write_text_file_contents(path, content)


def append_file_path_to_file(path: Path, file_path: str) -> None:
    """Append a file path to a list file, preventing duplicates.

    Args:
        path: Path to list file
        file_path: File path to append
    """
    existing_paths = read_file_paths_file(path)
    if file_path not in existing_paths:
        existing_paths.append(file_path)
        write_file_paths_file(path, existing_paths)


def is_path_blocked(path: str, blocked_files: Collection[str]) -> bool:
    """Return True when path is covered by the blocked-files list.

    A negation entry (!path) takes precedence over all other entries.
    An entry covers path if it equals path exactly, or if the entry ends
    with '/' and path starts with that prefix (directory match).
    """
    # Exact and directory negations are authoritative in the compatibility
    # state format, even when callers have loaded it into a set.
    for entry in blocked_files:
        if not entry.startswith("!"):
            continue
        included = entry[1:]
        if path == included or (included.endswith("/") and path.startswith(included)):
            return False
    for entry in reversed(list(blocked_files)):
        if entry.startswith("!"):
            continue
        elif path == entry or (entry.endswith("/") and path.startswith(entry)):
            return True
    return False


def remove_file_path_from_file(state_file_path: Path, file_path: str) -> None:
    """Remove a file path from a list file.

    Args:
        state_file_path: Path to list file
        file_path: File path to remove
    """
    existing_paths = read_file_paths_file(state_file_path)
    if file_path in existing_paths:
        existing_paths.remove(file_path)
        write_file_paths_file(state_file_path, existing_paths)
