#!/usr/bin/env python3
"""Pre-commit guard: Bearer ``bearer:disable`` directives must be well-formed.

Bearer SILENTLY ignores a suppression directive (the finding stays open) unless
it is written exactly as:

  * a line comment (``#`` in Python, ``//`` in JavaScript) on its OWN line,
    directly above the statement — a *same-line* trailing directive
    (``code()  # bearer:disable rule``) is ignored; and
  * the bare rule id(s) only, with NO trailing prose — ``# bearer:disable rule
    -- why`` is ignored. Put the rationale on a separate comment line above the
    bare directive; and
  * NOT inside a block comment — Bearer ignores ``/* bearer:disable rule */``
    and JSDoc ``* bearer:disable rule`` too.

Each failure mode is silent, so a malformed directive looks like protection
while suppressing nothing. This hook fails the commit when it finds one.

Implementation notes:
  * Python uses ``tokenize`` — directives mentioned inside docstrings or string
    literals are not flagged, only real ``#`` comments.
  * JavaScript uses a small char scanner that tracks strings/templates and
    block comments, so a ``//`` inside a string (e.g. a URL) is not mistaken
    for a comment. Not special-cased (rare edges, accepted): regex literals,
    and code inside template ``${...}`` interpolations (a directive written
    inside ``${...}`` is treated as template text and would be missed) — no
    real directive is written in either place.
"""

from __future__ import annotations

import io
import re
import sys
import tokenize

# A Bearer rule id is always namespaced with underscores, e.g.
# python_lang_sql_injection / javascript_lang_dangerous_insert_html — never a
# bare English word. Requiring an underscore stops lowercase prose
# ("because reasons") from masquerading as a rule id.
_RULE = r"[a-z][a-z0-9]*(?:_[a-z0-9]+)+"
# After `bearer:disable`: one or more rule ids, comma-separated only (Bearer's
# documented multi-rule syntax), and nothing else.
_VALID_AFTER = re.compile(rf"^[ \t]+{_RULE}(?:[ \t]*,[ \t]*{_RULE})*[ \t]*$")
# A Python `#` comment whose content begins with the directive.
_DIRECTIVE_START = re.compile(r"^#[ \t]*bearer:disable\b(.*)$")
# An embedded `# bearer:disable` (a directive trailing another comment).
_EMBEDDED = re.compile(r"#[ \t]*bearer:disable\b")
# A `// bearer:disable ...` line comment (used to read the bit after the rule).
_JS_LINE_DIRECTIVE = re.compile(r"^//[ \t]*bearer:disable\b(.*)$")
# A block-comment line that STARTS with the directive (after `/*` / JSDoc `*`),
# vs. one that merely mentions it in prose.
_BLOCK_DIRECTIVE_LINE = re.compile(
    r"^[ \t]*(?:/\*+|\*+)?[ \t]*bearer:disable\b"
)

_SAME_LINE_MSG = (
    "same-line `bearer:disable` is silently ignored by Bearer — put the bare "
    "directive on its own line directly above the statement"
)
_TRAILING_MSG = (
    "trailing text after the rule id is silently ignored by Bearer — keep the "
    "directive line as the bare rule id and move the rationale to a separate "
    "comment line above it"
)
_BLOCK_MSG = (
    "`bearer:disable` in a block comment is silently ignored by Bearer — use a "
    "line comment (// or #) on its own line directly above the statement"
)
_MISSING_MSG = "`bearer:disable` is missing a rule id"


def _after_violation(after: str) -> str | None:
    """Validate the text that follows ``bearer:disable``."""
    if not after.strip():
        return _MISSING_MSG
    if not _VALID_AFTER.match(after):
        return _TRAILING_MSG
    return None


def _check_python(content: str) -> list[tuple[int, str]]:
    errors: list[tuple[int, str]] = []
    lines = content.splitlines()
    try:
        tokens = list(tokenize.generate_tokens(io.StringIO(content).readline))
    except (tokenize.TokenError, IndentationError, SyntaxError):
        return errors  # malformed files are caught by other tools
    for tok in tokens:
        if tok.type != tokenize.COMMENT or "bearer:disable" not in tok.string:
            continue
        row, col = tok.start
        code_before = (
            lines[row - 1][:col].strip() if row - 1 < len(lines) else ""
        )
        m = _DIRECTIVE_START.match(tok.string)
        if m:
            if code_before:
                errors.append((row, _SAME_LINE_MSG))
            else:
                msg = _after_violation(m.group(1))
                if msg:
                    errors.append((row, msg))
        elif code_before and _EMBEDDED.search(tok.string):
            # A real directive trailing another comment on a code line, e.g.
            # `run(q)  # noqa: S608  # bearer:disable rule`.
            errors.append((row, _SAME_LINE_MSG))
        # else: a comment that only mentions the text in prose — not a directive.
    return errors


def _classify_js_line_comment(
    lineno: int, code_before: str, comment: str, errors: list[tuple[int, str]]
) -> None:
    m = _JS_LINE_DIRECTIVE.match(comment)
    if not m:
        return  # `// other prose ... bearer:disable ...` — not a directive
    if code_before.strip():
        errors.append((lineno, _SAME_LINE_MSG))
    else:
        msg = _after_violation(m.group(1))
        if msg:
            errors.append((lineno, msg))


def _check_js(content: str) -> list[tuple[int, str]]:
    """Char scanner: only `bearer:disable` reached as a real comment counts."""
    errors: list[tuple[int, str]] = []
    n = len(content)
    i = 0
    line = 1
    line_start = 0
    state = "code"  # code | block | sq | dq | tmpl
    block_start_i = 0
    block_start_line = 0

    def flag_block(text: str, start_line: int) -> None:
        # Flag only a block line that STARTS with the directive (a real
        # block-comment suppression), not prose that mentions it.
        for off, ln in enumerate(text.splitlines()):
            if _BLOCK_DIRECTIVE_LINE.match(ln):
                errors.append((start_line + off, _BLOCK_MSG))
                return

    while i < n:
        ch = content[i]
        nxt = content[i + 1] if i + 1 < n else ""
        if ch == "\n":
            line += 1
            line_start = i + 1
            i += 1
            continue
        if state == "code":
            if ch == "/" and nxt == "/":
                eol = content.find("\n", i)
                if eol == -1:
                    eol = n
                _classify_js_line_comment(
                    line, content[line_start:i], content[i:eol], errors
                )
                i = eol
            elif ch == "/" and nxt == "*":
                state, block_start_i, block_start_line = "block", i, line
                i += 2
            elif ch == '"':
                state = "dq"
                i += 1
            elif ch == "'":
                state = "sq"
                i += 1
            elif ch == "`":
                state = "tmpl"
                i += 1
            else:
                i += 1
        elif state == "block":
            if ch == "*" and nxt == "/":
                flag_block(content[block_start_i : i + 2], block_start_line)
                state = "code"
                i += 2
            else:
                i += 1
        else:  # sq | dq | tmpl
            quote = {"sq": "'", "dq": '"', "tmpl": "`"}[state]
            if ch == "\\":
                i += 2
            elif ch == quote:
                state = "code"
                i += 1
            else:
                i += 1
    if state == "block":  # unterminated block comment
        flag_block(content[block_start_i:n], block_start_line)
    return errors


def check_file(filename: str) -> list[tuple[int, str]]:
    try:
        # utf-8-sig strips a leading BOM so it is not mistaken for code.
        with open(filename, "r", encoding="utf-8-sig") as fh:
            content = fh.read()
    except (UnicodeDecodeError, OSError):
        return []
    if filename.endswith(".py"):
        return _check_python(content)
    if filename.endswith(".js"):
        return _check_js(content)
    return []


def main(argv: list[str]) -> int:
    failed = False
    for filename in argv:
        errors = check_file(filename)
        if errors:
            failed = True
            print(f"\n{filename}:")
            for line_num, msg in sorted(errors):
                print(f"  Line {line_num}: {msg}")
    if failed:
        print(
            "\n❌ Malformed `bearer:disable` directive(s). Bearer only honors a "
            "directive that is\n   the bare rule id on its own line directly "
            "above the statement:\n"
            "       # bearer:disable python_lang_sql_injection\n"
            "       <statement>\n"
            "   Put any rationale on separate comment line(s) above it."
        )
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
