#!/usr/bin/env python3
"""
Pre-commit hook to detect potential logging of sensitive data.
Prevents passwords, tokens, and other sensitive information from being logged.

Note: this hook is part of the reason we do NOT enforce ``raise X from e``
universally (see ADR-0003). Exception chains preserved via ``from e`` can
leak PII through error-tracking services and downstream handlers. Code that
wraps user-facing exceptions should omit ``from e`` to break the chain.
"""

import ast
import sys
from pathlib import Path
from typing import List, Optional


SENSITIVE_VARS = {
    "password",
    "user_password",
    "pwd",
    "secret",
    "api_key",
    "private_key",
    "auth",
    "credential",
    "csrf_token",  # CSRF tokens should not be logged
    "bearer_token",
    "access_token",
    "refresh_token",
    "auth_token",
    "session_id",  # Can be used to retrieve passwords
    "encryption_key",
    "secret_key",
    "auth_header",
}

SENSITIVE_DICT_KEYS = {
    "shared_research_context",  # Contains user_password for SQLCipher
    "settings_snapshot",  # Contains API keys and other sensitive data
    "kwargs",  # Often contains user_password and gets passed through many functions
    "credentials",  # Obviously contains authentication data
    "auth_data",  # Authentication related data
    "auth_context",  # Authentication context
    "user_data",  # May contain user passwords
    "login_data",  # Login form data including passwords
    "form_data",  # Form submissions may contain passwords
    "post_data",  # POST request data
    "payload",  # API request payloads
    "body",  # Request body data
    "session",  # Flask session data (when used as dict)
    "flask_session",  # Explicit Flask session
    "g.user_password",  # Flask g object with password
    "connection_string",  # Database connection strings with passwords
    "conn_str",
    "db_url",
    "database_url",
    "conn_params",
    "db_config",
    "environ",  # os.environ contains sensitive vars
}

# These are false positives - specific variable names that are safe to log
FALSE_POSITIVE_VARS = {
    # Token counting related - these are LLM tokens, not auth tokens
    "prompt_tokens",
    "completion_tokens",
    "token_research_count",
    "tokens_truncated",
    "estimated_prompt_tokens",
    "estimated_completion_tokens",
    "max_tokens",
    "new_max_tokens",
    # Configuration paths and names (not the actual config contents)
    "config_path",
    "config_name",
    "config_hash",
    "context_window_size",
    # Database paths (not credentials)
    "auth_db_path",
    # Safe/redacted versions
    "safe_settings",  # This is already redacted
}

# Specific file + variable combinations that are allowed
# Each entry must have an explanation of why it's safe
ALLOWED_LOGGING = [
    {
        "file_pattern": "settings/logger.py",
        "variable": "safe_settings",
        "reason": "This variable contains already redacted settings with sensitive values replaced by ***REDACTED***",
    },
    {
        "file_pattern": "settings/logger.py",
        "variable": "settings",
        "reason": "This module is responsible for safe logging and applies redaction before logging when log_level is 'debug_unsafe'",
    },
    {
        "file_pattern": "tests/",
        "variable": "csrf_token",
        "reason": "Test files need to log CSRF tokens for debugging authentication issues during test development",
    },
    {
        "file_pattern": "web/services/socket_service.py",
        "variable": "kwargs",
        "reason": "These are logger wrapper methods where kwargs are logger configuration options (like exc_info, extra), not actual data being logged",
    },
    {
        "file_pattern": "search_engine_openlibrary.py",
        "variable": "author_key",
        "reason": "OpenLibrary author identifier (e.g. /authors/OL123A), not a cryptographic key",
    },
    {
        "file_pattern": "web/queue/processor_v2.py",
        "variable": "user_session",
        "reason": "Queue routing key (username:session_id format), not session contents",
    },
]


class SensitiveLoggingChecker(ast.NodeVisitor):
    """AST visitor to detect sensitive data in logging statements."""

    def __init__(self, filename: str):
        self.filename = filename
        self.errors: List[str] = []
        # Track exception variable names from enclosing except handlers
        self._except_var_stack: List[Optional[str]] = []

    def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
        """Track exception variable names inside except blocks."""
        self._except_var_stack.append(node.name)
        self.generic_visit(node)
        self._except_var_stack.pop()

    def visit_Call(self, node: ast.Call) -> None:
        """Check function calls for logging sensitive data."""
        # Check if it's a logger call
        if self._is_logger_call(node):
            self._check_sensitive_logging(node)
            self._check_exc_info_on_warning(node)
            self._check_exception_var_in_log(node)

        self.generic_visit(node)

    def _is_logger_call(self, node: ast.Call) -> bool:
        """Check if the call is to a logger method."""
        if isinstance(node.func, ast.Attribute):
            # Check for logger.info, logger.debug, etc.
            attr_name = node.func.attr
            if attr_name in {
                "info",
                "debug",
                "warning",
                "error",
                "critical",
                "exception",
                "log",
            }:
                if isinstance(node.func.value, ast.Name):
                    return node.func.value.id == "logger"
                if isinstance(node.func.value, ast.Attribute):
                    # Handle self.logger, cls.logger, etc.
                    return node.func.value.attr == "logger"
        return False

    def _check_sensitive_logging(self, node: ast.Call) -> None:
        """Check if sensitive data is being logged."""
        for arg in node.args:
            self._check_expression_for_sensitive_data(arg, node.lineno)

        for keyword in node.keywords:
            self._check_expression_for_sensitive_data(
                keyword.value, node.lineno
            )

    def _get_log_level(self, node: ast.Call) -> Optional[str]:
        """Return the logging level name (e.g. 'warning', 'debug') or None."""
        if isinstance(node.func, ast.Attribute):
            return node.func.attr
        return None

    def _check_exc_info_on_warning(self, node: ast.Call) -> None:
        """Flag exc_info=True on warning/error/critical level logs.

        exc_info dumps full tracebacks which are visible in production.
        Use logger.exception() for dev-only traceback logging, or
        logger.debug(..., exc_info=True) which is off in production.
        """
        level = self._get_log_level(node)
        if level not in {"warning", "error", "critical"}:
            return
        for kw in node.keywords:
            if (
                kw.arg == "exc_info"
                and isinstance(kw.value, ast.Constant)
                and kw.value.value is True
            ):
                self.errors.append(
                    f"{self.filename}:{node.lineno}: "
                    f"exc_info=True on logger.{level}() exposes tracebacks in production — "
                    f"use logger.exception() or logger.debug(..., exc_info=True) instead"
                )

    def _check_exception_var_in_log(self, node: ast.Call) -> None:
        """Flag logging calls that interpolate the exception variable.

        Patterns like logger.warning(f"...{e}") or logger.warning("...%s", e)
        leak error details. Use logger.exception() instead, which is
        configured for dev-only output.
        """
        level = self._get_log_level(node)
        # logger.exception() and logger.debug() are fine — not visible in production
        if level in {"exception", "debug"}:
            return

        # Collect current except variable names
        except_vars = {v for v in self._except_var_stack if v is not None}
        if not except_vars:
            return

        # Check positional args (f-strings and %-style args)
        for arg in node.args:
            if self._expr_references_vars(arg, except_vars):
                self.errors.append(
                    f"{self.filename}:{node.lineno}: "
                    f"Exception variable in logger.{level}() leaks error details — "
                    f"use logger.exception() or remove the variable"
                )
                return

    def _expr_references_vars(self, expr: ast.AST, var_names: set) -> bool:
        """Check if an expression references any of the given variable names."""
        if isinstance(expr, ast.Name):
            return expr.id in var_names
        if isinstance(expr, ast.JoinedStr):
            # f-string: check each formatted value
            return any(
                isinstance(v, ast.FormattedValue)
                and self._expr_references_vars(v.value, var_names)
                for v in expr.values
            )
        if isinstance(expr, ast.Call):
            # str(e), repr(e), type(e), etc.
            return any(
                self._expr_references_vars(a, var_names) for a in expr.args
            )
        if isinstance(expr, ast.BinOp):
            # "message: %s" % e
            return self._expr_references_vars(
                expr.right, var_names
            ) or self._expr_references_vars(expr.left, var_names)
        if isinstance(expr, ast.Tuple):
            # ("message %s %s", e, other)
            return any(
                self._expr_references_vars(el, var_names) for el in expr.elts
            )
        return False

    def _is_allowed_logging(self, variable_name: str) -> bool:
        """Check if this specific logging is allowed."""
        for allowed in ALLOWED_LOGGING:
            if (
                allowed["file_pattern"] in self.filename
                and allowed["variable"] == variable_name
            ):
                return True
        return False

    def _check_expression_for_sensitive_data(
        self, expr: ast.AST, lineno: int
    ) -> None:
        """Recursively check an expression for sensitive data."""
        if isinstance(expr, ast.Name):
            # Skip if it's a known false positive
            if expr.id in FALSE_POSITIVE_VARS:
                return

            # Skip if it's specifically allowed for this file
            if self._is_allowed_logging(expr.id):
                return

            # Direct variable logging
            if any(
                sensitive in expr.id.lower() for sensitive in SENSITIVE_VARS
            ):
                self.errors.append(
                    f"{self.filename}:{lineno}: Potential logging of sensitive variable '{expr.id}'"
                )
            # Check for sensitive dictionaries being logged directly
            if any(
                sensitive in expr.id.lower()
                for sensitive in SENSITIVE_DICT_KEYS
            ):
                self.errors.append(
                    f"{self.filename}:{lineno}: Potential logging of sensitive dictionary '{expr.id}'"
                )

        elif isinstance(expr, ast.Attribute):
            # Check for Flask request object attributes
            if isinstance(expr.value, ast.Name) and expr.value.id == "request":
                if expr.attr in {
                    "form",
                    "json",
                    "data",
                    "headers",
                    "authorization",
                    "cookies",
                }:
                    self.errors.append(
                        f"{self.filename}:{lineno}: Potential logging of sensitive Flask request.{expr.attr} which may contain passwords or tokens"
                    )
                    return

            # Check for Flask g object attributes
            if isinstance(expr.value, ast.Name) and expr.value.id == "g":
                if (
                    "password" in expr.attr.lower()
                    or "token" in expr.attr.lower()
                ):
                    self.errors.append(
                        f"{self.filename}:{lineno}: Potential logging of sensitive Flask g.{expr.attr}"
                    )
                    return

            # Check for os.environ
            if (
                isinstance(expr.value, ast.Name)
                and expr.value.id == "os"
                and expr.attr == "environ"
            ):
                self.errors.append(
                    f"{self.filename}:{lineno}: Potential logging of os.environ which contains environment variables"
                )
                return

            # Check for dict.keys(), dict.values(), dict.items()
            if expr.attr in {"keys", "values", "items"}:
                if isinstance(expr.value, ast.Name):
                    if any(
                        sensitive in expr.value.id.lower()
                        for sensitive in SENSITIVE_DICT_KEYS
                    ):
                        self.errors.append(
                            f"{self.filename}:{lineno}: Potential logging of sensitive dictionary keys/values from '{expr.value.id}.{expr.attr}()'"
                        )

            # Check for sensitive attributes like obj.password, obj.api_key, etc.
            if any(
                sensitive in expr.attr.lower() for sensitive in SENSITIVE_VARS
            ):
                # Skip false positives
                if expr.attr in FALSE_POSITIVE_VARS:
                    return
                self.errors.append(
                    f"{self.filename}:{lineno}: Potential logging of sensitive attribute '{expr.attr}'"
                )

        elif isinstance(expr, ast.Call):
            # Check for list(), str(), repr() of sensitive dicts
            if isinstance(expr.func, ast.Name):
                if expr.func.id in {"list", "str", "repr"}:
                    for arg in expr.args:
                        # Check for list(dict.keys()), list(dict.values()), etc.
                        if isinstance(arg, ast.Call) and isinstance(
                            arg.func, ast.Attribute
                        ):
                            if arg.func.attr in {"keys", "values", "items"}:
                                if isinstance(arg.func.value, ast.Name):
                                    if any(
                                        sensitive in arg.func.value.id.lower()
                                        for sensitive in SENSITIVE_DICT_KEYS
                                    ):
                                        self.errors.append(
                                            f"{self.filename}:{lineno}: Potential logging of sensitive dictionary keys/values via '{expr.func.id}({arg.func.value.id}.{arg.func.attr}())'"
                                        )
                        # Also check direct conversion of sensitive dicts
                        elif isinstance(arg, ast.Name):
                            if any(
                                sensitive in arg.id.lower()
                                for sensitive in SENSITIVE_DICT_KEYS
                            ):
                                self.errors.append(
                                    f"{self.filename}:{lineno}: Potential logging of sensitive dictionary via '{expr.func.id}({arg.id})'"
                                )

        elif isinstance(expr, ast.JoinedStr):
            # Check f-strings
            for value in expr.values:
                if isinstance(value, ast.FormattedValue):
                    self._check_expression_for_sensitive_data(
                        value.value, lineno
                    )

        elif isinstance(expr, ast.Dict):
            # Check dictionary literals for sensitive keys
            for key in expr.keys:
                if isinstance(key, ast.Constant) and isinstance(key.value, str):
                    if any(
                        sensitive in key.value.lower()
                        for sensitive in SENSITIVE_VARS
                    ):
                        self.errors.append(
                            f"{self.filename}:{lineno}: Potential logging of dictionary with sensitive key '{key.value}'"
                        )

        elif isinstance(expr, ast.BinOp):
            # Check string formatting
            self._check_expression_for_sensitive_data(expr.left, lineno)
            self._check_expression_for_sensitive_data(expr.right, lineno)


def check_file(filepath: Path) -> List[str]:
    """Check a single Python file for sensitive logging."""
    try:
        with open(filepath, "r", encoding="utf-8") as f:
            content = f.read()

        tree = ast.parse(content, filename=str(filepath))
        checker = SensitiveLoggingChecker(str(filepath))
        checker.visit(tree)
        return checker.errors

    except SyntaxError as e:
        return [f"{filepath}:{e.lineno}: Syntax error - {e.msg}"]
    except Exception as e:
        return [f"{filepath}: Error checking file - {e}"]


def main():
    """Main entry point for the pre-commit hook."""
    files = sys.argv[1:]
    all_errors = []

    for filename in files:
        filepath = Path(filename)
        if filepath.suffix == ".py":
            errors = check_file(filepath)
            all_errors.extend(errors)

    if all_errors:
        print("Sensitive data logging detected:")
        for error in all_errors:
            print(f"  {error}")
        print("\nPlease ensure sensitive data is not logged directly.")
        print("Consider:")
        print("  - Removing sensitive data from log messages")
        print("  - Using sanitized versions of data structures")
        print("  - Logging only necessary non-sensitive information")
        return 1

    return 0


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