"""icpplint - check ICPP source against the language's own conventions.

    python tools/icpplint.py <file>...        check the files named
    python tools/icpplint.py www/*.icpp.html  templates
    python tools/icpplint.py script.icpp      a standalone script

Exit code is the number of problems found, so it drops straight into a build
step or a pre-commit hook.

TWO DIALECTS, ONE RULE SET.

  *.icpp, *.c        a standalone script: `void main()`, functions, includes.
  *.icpp.html, *.tpl a template. icpptpl wraps every `<?icpp ... ?>` block on
                     the page into a single generated `main()`, so the whole
                     page is one function body and only the `<?icpp ?>` parts
                     are code.

The checker reads only ICPP code - inside `<?icpp ... ?>` for a template, the
whole file otherwise - and blanks comments and string bodies first, so prose
in a message cannot trip a rule.

THE RULES

1. VARIABLES IN A SCRIPT YOU WRITE ARE `$var`.
   This is a CONVENTION, not a validity rule. ICPP interprets the full C type
   system - int, double, char *, arrays, structs - and a typed local is
   perfectly legal ICPP that this checker is asking you not to write by hand.
   `$var` is the addition: no declaration, and its type is whatever it is
   assigned.

   So the report says "use $n", never "error". A file full of typed locals
   runs correctly; it just is not the style this checks for. Point the checker
   at hand-written scripts and templates, not at generated code or at
   test/icpp_conformance/*.icpp, which exercises typed C deliberately.

   This was NOT always the right advice. A `$var` used to be written only by
   its parse-time initializer, so the first assignment won and every later one
   was silently dropped - reassignment, loop accumulation and `$v = f();`
   ordering were all broken, and the manual told people to use typed locals
   instead. That defect is fixed: assignments now happen at run time in
   statement order, which test/icpp_conformance/10_dollar_var.icpp pins with
   16 checks including reassignment, self-reference, loop accumulation and
   assignment inside an `if` branch.

   Function signatures and parameters are NOT flagged - `int twice(int n)` is
   how a typed function is declared, and `function` defines a typeless one.
   Only declarations of scalar local variables are. An array declaration
   (`int buf[64];`) is left alone because it cannot be written as a $var, and
   a struct declaration names its own type rather than a builtin one.

2. NO BARE IDENTIFIER ON THE LEFT OF `=`.
   `total = 5;` without a declaration or a `$` is not a variable, and what it
   does depends on the interpreter rather than on intent.

3. A `$var` READ SOMEWHERE IS ASSIGNED SOMEWHERE.
   A misspelled `$var` is not an error in ICPP. It is simply empty, and the
   output has a hole in it that nobody notices until someone asks why a column
   is blank.

4. (templates, advisory) A `$var` IS SEEDED AT THE TOP LEVEL.
   Reported as a note rather than a problem, and it does not affect the exit
   code. Assignment inside a block works - rule 1 says so - but in a template
   a `$var` that only ever comes into existence down one branch renders as
   nothing on the others, and seeding it at the top of the page is the cheap
   way to never think about it again.
"""
from __future__ import annotations

import glob
import re
import sys
from pathlib import Path

BLOCK = re.compile(r"<\?icpp(.*?)\?>", re.S)
BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.S)
LINE_COMMENT = re.compile(r"//[^\n]*")
STRING = re.compile(r'"(?:\\.|[^"\\])*"')
CHAR_LIT = re.compile(r"'(?:\\.|[^'\\])'")

TYPES = r"int|char|long|short|float|double|unsigned|signed|void|bool"
# A local variable declaration: a type, optional stars, a name, and then
# something that is not `(`. The trailing check is what separates
# `int count;` from `int twice(int n)`.
# `[` is deliberately absent from the lookahead: `int buf[64];` is an array,
# which has no $var form, so flagging it would be asking for something that
# cannot be written.
TYPED_LOCAL = re.compile(
    r"(?<![$\w])(" + TYPES + r")\s+(\*\s*)*([A-Za-z_]\w*)\s*(?=[;,=])")
BARE_ASSIGN = re.compile(r"(?<![$\w.>])([A-Za-z_]\w*)\s*=(?!=)")
DOLLAR_ASSIGN = re.compile(r"\$(\w+)\s*=(?!=)")
DOLLAR_USE = re.compile(r"\$(\w+)")

KEYWORDS = {"if", "while", "for", "else", "return", "do", "switch", "case",
            "function", "struct", "typedef", "sizeof", "break", "continue"}

TEMPLATE_SUFFIXES = (".icpp.html", ".html", ".tpl")


def is_template(path: Path) -> bool:
    name = path.name.lower()
    return any(name.endswith(suffix) for suffix in TEMPLATE_SUFFIXES)


def extract(path: Path) -> str:
    """The ICPP code in a file, with comments and string bodies blanked.

    Newlines are preserved everywhere something is removed so that reported
    line numbers still point at the right line.
    """
    source = path.read_text(encoding="utf-8", errors="replace")
    if is_template(path):
        pieces = []
        cursor = 0
        for match in BLOCK.finditer(source):
            # Keep the newlines of the HTML that was skipped, so a line number
            # in the report matches the line number in the file.
            pieces.append("\n" * source.count("\n", cursor, match.start(1)))
            pieces.append(match.group(1))
            cursor = match.end(1)
        code = "".join(pieces)
    else:
        code = source

    code = BLOCK_COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), code)
    code = LINE_COMMENT.sub("", code)
    code = STRING.sub('""', code)
    return CHAR_LIT.sub("' '", code)


def depths(code: str) -> tuple[list[int], list[int]]:
    """Brace depth and parenthesis depth before each character."""
    brace = []
    paren = []
    b = p = 0
    for ch in code:
        brace.append(b)
        paren.append(p)
        if ch == "{":
            b += 1
        elif ch == "}":
            b = max(0, b - 1)
        elif ch == "(":
            p += 1
        elif ch == ")":
            p = max(0, p - 1)
    brace.append(b)
    paren.append(p)
    return brace, paren


def line_of(code: str, offset: int) -> int:
    return code.count("\n", 0, offset) + 1


def check(path: Path) -> tuple[list[str], list[str]]:
    """Returns (problems, notes)."""
    code = extract(path)
    if not code.strip():
        return [], []

    brace, paren = depths(code)
    problems: list[str] = []
    notes: list[str] = []
    where = lambda offset: f"{path.name}:{line_of(code, offset)}"  # noqa: E731

    for match in TYPED_LOCAL.finditer(code):
        # Inside parentheses this is a parameter, not a local.
        if paren[match.start()] > 0:
            continue
        declared = " ".join(match.group(0).split())
        problems.append(
            f"{where(match.start())}: typed local "
            f"'{declared}' - the convention is ${match.group(3)}")

    for match in BARE_ASSIGN.finditer(code):
        name = match.group(1)
        if name in KEYWORDS:
            continue
        # `int n = 0;` is already reported as a typed local; do not also
        # report the assignment half of the same declaration.
        prefix = code[max(0, match.start() - 24):match.start()]
        if re.search(r"(?<![$\w])(" + TYPES + r")\s+(\*\s*)*$", prefix):
            continue
        problems.append(f"{where(match.start())}: assignment to bare "
                        f"'{name}' - should be ${name}")

    assigned: dict[str, int] = {}
    for match in DOLLAR_ASSIGN.finditer(code):
        assigned.setdefault(match.group(1), match.start())

    for name in sorted({m.group(1) for m in DOLLAR_USE.finditer(code)} - set(assigned)):
        problems.append(f"{path.name}: ${name} is read but never assigned - "
                        f"a misspelled $var is silently empty")

    if is_template(path):
        for name, offset in sorted(assigned.items(), key=lambda item: item[1]):
            if brace[offset] > 0:
                notes.append(
                    f"{where(offset)}: ${name} is first assigned inside a "
                    f"block - consider seeding it at the top of the page")

    return problems, notes


def main(argv: list[str]) -> int:
    targets: list[Path] = []
    for argument in argv[1:]:
        matched = glob.glob(argument)
        targets.extend(Path(p) for p in (matched or [argument]))

    targets = [t for t in targets if t.is_file()]
    if not targets:
        print(__doc__.split("\n\n")[1].strip(), file=sys.stderr)
        return 2

    total = 0
    for path in sorted(targets):
        problems, notes = check(path)
        total += len(problems)
        if problems:
            for problem in problems:
                print(f"  {problem}")
        else:
            kind = "template" if is_template(path) else "script"
            print(f"  [ok ] {path.name}  ({kind})")
        for note in notes:
            print(f"  note: {note}")

    print()
    print(f"{len(targets)} file(s) checked, {total} problem(s)")
    return total


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
