#!/usr/bin/env python3
"""
Pre-commit hook to enforce explicit ``encoding=`` on text-mode file I/O calls.

On Windows the default encoding is the system locale (often cp1252), not UTF-8.
Omitting ``encoding`` causes silent failures or ``UnicodeDecodeError`` when
reading/writing UTF-8 files.  See issue #3743.

Detects:
  * Bare ``open(...)`` — second positional arg is the mode.
  * ``<expr>.open(<mode>, ...)`` — only when the first positional arg is a
    constant string that looks like a real file mode (avoids false positives
    on ``tarfile.open`` / ``zipfile.open`` etc., which take a path first).
  * ``<expr>.read_text(...)`` and ``<expr>.write_text(...)`` — these are
    effectively pathlib-only, so any bare call without ``encoding=`` is flagged.
"""

import ast
import sys
from pathlib import Path

_FILE_MODE_CHARS = frozenset("rwxabt+")


def _looks_like_file_mode(value: object) -> bool:
    """True if value is a string that plausibly is a file ``open`` mode."""
    return (
        isinstance(value, str)
        and 0 < len(value) <= 3
        and set(value) <= _FILE_MODE_CHARS
    )


def _has_encoding_keyword(call: ast.Call) -> bool:
    """True if the call has an explicit ``encoding=`` kwarg.

    Treats ``**kwargs`` spreads as "may contain encoding" to avoid false
    positives — we can't statically prove the spread doesn't supply it.
    """
    for kw in call.keywords:
        if kw.arg is None:  # **kwargs spread
            return True
        if kw.arg == "encoding":
            return True
    return False


def _get_mode_arg(call: ast.Call, positional_index: int) -> ast.expr | None:
    """Return the mode AST node, looking at both positional and ``mode=`` kwarg.

    Returns None if no mode was supplied (caller defaults to text mode).
    """
    if len(call.args) > positional_index:
        return call.args[positional_index]

    for kw in call.keywords:
        if kw.arg == "mode":
            return kw.value

    return None


def _is_text_mode_at(call: ast.Call, mode_arg_index: int) -> bool | None:
    """Return True if the mode argument is text mode.

    Inspects both the positional slot and the ``mode=`` kwarg, so calls like
    ``open(f, mode="rb")`` are correctly classified as binary even though the
    positional slot is empty.

    Returns ``None`` (falsy) when the mode can't be determined statically —
    e.g. ``open(filepath, mode)`` where ``mode`` is a variable.
    """
    mode_arg = _get_mode_arg(call, mode_arg_index)
    if mode_arg is None:
        return True  # default mode is "r" (text)

    if not isinstance(mode_arg, ast.Constant):
        return None

    return "b" not in str(mode_arg.value)


def _violations_for_call(node: ast.Call) -> list[tuple[int, str]]:
    func = node.func

    # Bare open(...) — second positional arg is mode.
    if isinstance(func, ast.Name) and func.id == "open":
        if _is_text_mode_at(node, 1) and not _has_encoding_keyword(node):
            return [(node.lineno, "open() called without explicit encoding=")]
        return []

    if not isinstance(func, ast.Attribute):
        return []

    attr = func.attr

    # Path.read_text() / Path.write_text() — encoding= is the only safe option.
    if attr in {"read_text", "write_text"}:
        if not _has_encoding_keyword(node):
            return [
                (
                    node.lineno,
                    f".{attr}() called without explicit encoding=",
                )
            ]
        return []

    # <expr>.open(<mode>, ...) — first positional arg is the mode (or mode= kwarg).
    # When the mode looks like a file mode (or is omitted entirely, defaulting
    # to "r"), flag missing encoding. The mode-shape filter avoids false
    # positives on tarfile.open("foo.tar") / zipfile.ZipFile.open("inner") etc.
    # — bare ``.open()`` is the same gap that bare ``open(f)`` already catches.
    if attr == "open":
        mode_arg = _get_mode_arg(node, 0)
        flag = False
        if mode_arg is None:
            flag = True  # defaults to "r" — same risk as bare open(f)
        elif isinstance(mode_arg, ast.Constant) and _looks_like_file_mode(
            mode_arg.value
        ):
            flag = "b" not in mode_arg.value
        if flag and not _has_encoding_keyword(node):
            return [
                (
                    node.lineno,
                    ".open() called without explicit encoding=",
                )
            ]

    return []


def check_file(file_path: str) -> list[tuple[int, str]]:
    path = Path(file_path)
    if path.suffix != ".py":
        return []

    try:
        source = path.read_text(encoding="utf-8")
    except Exception:
        return []

    try:
        tree = ast.parse(source, filename=file_path)
    except SyntaxError:
        return []

    violations: list[tuple[int, str]] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Call):
            violations.extend(_violations_for_call(node))

    return violations


def main() -> int:
    exit_code = 0

    for file_path in sys.argv[1:]:
        violations = check_file(file_path)
        for lineno, message in violations:
            print(f"{file_path}:{lineno}: {message}")
            exit_code = 1

    if exit_code:
        print()
        print(
            "Hint: add encoding='utf-8' (or 'utf-8-sig' for JSON config files)"
        )
        print(
            "      to all text-mode open() / Path.open() / read_text() / write_text() calls."
        )
        print("      See issue #3743.")

    return exit_code


if __name__ == "__main__":
    sys.exit(main())
