#!/usr/bin/env python3
# Copyright (c) 2025-2026 Rocky Software Inc.
#
# This tool ships to customers as reference material. You may freely copy,
# modify, and incorporate it into your own build. This permission does NOT
# extend to the RockyGuard library itself, which is governed by the
# RockyGuard Library Software License Agreement.
#
# rg_bind_lint -- RockyGuard load-bearing integration linter (v1.4+)
# ---------------------------------------------------------------------------
# Heuristic checks that a RockyGuard integration has NOT collapsed the
# load-bearing pattern (integration guide section 6.5) back into a flippable
# boolean gate. It is a lint, not a proof: it catches the common regressions
# and reminds you of the negative test. Green here is necessary, not
# sufficient -- still run the wrong-licence test from section 10.
#
# Usage:
#   python tools/rg_bind_lint [PATH ...]        # default: current directory
#   python tools/rg_bind_lint --quiet src/      # errors only
#
# Exit code: 0 if no ERROR findings, 1 otherwise.
import argparse
import os
import re
import sys

SRC_EXT = (".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".inl")

# Directory name fragments that are never the customer's own code.
SKIP_DIRS = ("build", ".git", "node_modules", "vcpkg", "_deps")

# (severity, regex, id, message). Severity: ERROR fails the lint; WARN/INFO don't.
RULES = [
    ("ERROR",
     re.compile(r'if\s*\(\s*!\s*[A-Za-z_][\w:.>()-]*unwrap_(data|feature)_key\s*\('),
     "unwrap-branch",
     "branch on unwrap_data_key()/unwrap_feature_key() -- reintroduces the "
     "flippable check. Use rockyguard::LicenseAsset (it has no optional to "
     "test) and feed open()'s result straight into real work."),

    ("ERROR",
     re.compile(r'\bbool\s+(is_licensed|license_ok|licensed|check_license|'
                r'is_valid_license|has_license|license_valid)\s*\('),
     "shared-bool-verdict",
     "a shared bool licence predicate -- one function to patch for all gates. "
     "Produce verdicts at the site, or bind per-feature (6.5 / 9.4)."),

    ("ERROR",
     re.compile(r'\.message\b[^;\n]*\b(find|rfind|compare)\s*\('),
     "message-text-branch",
     "branching on result.message text -- reworded between releases. "
     "Branch on result.status / result.code (sections 5, 11)."),
    ("ERROR",
     re.compile(r'\b(find|rfind|compare)\s*\([^;\n]*\.message\b'),
     "message-text-branch",
     "branching on result.message text -- reworded between releases. "
     "Branch on result.status / result.code (sections 5, 11)."),

    ("WARN",
     re.compile(r'if\s*\(\s*!?\s*\w*(key|dk|secret)\w*\.empty\s*\(\s*\)\s*\)[^;\n]*'
                r'(return|refuse|exit|abort|disable)', re.I),
     "empty-key-branch",
     "possible `if (key.empty()) refuse;` guard on an unwrapped key -- drop it. "
     "Hand the (possibly empty) key to the decrypt, or better, let "
     "LicenseAsset hold it; the empty plaintext makes the feature inert "
     "without a branch to flip."),

    ("WARN",
     re.compile(r'if\s*\(\s*!?\s*\w*(table|asset|plaintext|decrypted)\w*\.empty\s*\(\s*\)\s*\)'
                r'[^;\n]*(return|refuse|exit|abort|disable|throw)', re.I),
     "empty-asset-branch",
     "possible `if (asset.empty()) refuse;` on an opened asset -- that is the "
     "branch 6.5 exists to remove. Feed the bytes into the parser/loader and "
     "let it fail on garbage instead."),

    ("INFO",
     re.compile(r'\bstd::exit\s*\(|(?<![\w:])exit\s*\(|\bstd::abort\s*\(|(?<![\w:])abort\s*\('),
     "hard-exit",
     "exit()/abort() present -- fine in a CLI, but NEVER inside a plugin/shared "
     "library (kills the host) and never mid-action in a GUI (destroys unsaved "
     "work). Verify the host type against the section 5 table."),
]

CONSUME_RE      = re.compile(r'\b(open_bound_asset|open_with_key)\s*\(')
ASSET_OPEN_RE   = re.compile(r'\.\s*open(_string)?\s*\(')
ASSET_TYPE_RE   = re.compile(r'\bLicenseAsset\b')
UNWRAP_RE       = re.compile(r'\bunwrap_(data|feature)_key\s*\(')
NEG_TEST_RE     = re.compile(r'(foreign|wrong|invalid|absent|tamper)[\w ]*licen', re.I)


def iter_files(paths):
    for p in paths:
        if os.path.isfile(p):
            if p.endswith(SRC_EXT):
                yield p
            continue
        for root, dirs, files in os.walk(p):
            dirs[:] = [d for d in dirs
                       if not (d in SKIP_DIRS or d.startswith("build"))]
            for f in files:
                if f.endswith(SRC_EXT):
                    yield os.path.join(root, f)


def strip_line_comment(line):
    i = line.find("//")
    return line if i < 0 else line[:i]


def main():
    ap = argparse.ArgumentParser(description="RockyGuard load-bearing linter")
    ap.add_argument("paths", nargs="*", default=["."])
    ap.add_argument("--quiet", action="store_true", help="show ERROR findings only")
    args = ap.parse_args()
    paths = args.paths or ["."]

    findings = []          # (severity, file, lineno, id, msg)
    binds_key = False      # unwrap_*_key() called or LicenseAsset used anywhere
    consumes_asset = False # something actually decrypts an asset
    has_negative_test = False

    for path in iter_files(paths):
        try:
            with open(path, "r", errors="replace") as fh:
                lines = fh.readlines()
        except OSError:
            continue
        file_text = "".join(lines)
        file_uses_asset_type = bool(ASSET_TYPE_RE.search(file_text))
        if file_uses_asset_type:
            binds_key = True
        for n, raw in enumerate(lines, 1):
            line = strip_line_comment(raw)
            if UNWRAP_RE.search(line):
                binds_key = True
            if CONSUME_RE.search(line):
                consumes_asset = True
            # `.open(` is common (streams, files); only count it as an asset
            # decrypt in a file that also names LicenseAsset.
            if file_uses_asset_type and ASSET_OPEN_RE.search(line):
                consumes_asset = True
            if NEG_TEST_RE.search(raw):     # scan comments too for the test
                has_negative_test = True
            for sev, rx, rid, msg in RULES:
                if rx.search(line):
                    findings.append((sev, path, n, rid, msg))

    # Structural findings (whole-tree).
    if binds_key and not consumes_asset:
        findings.append(("ERROR", "(whole tree)", 0, "key-not-consumed",
                         "a licence key is recovered (unwrap_*_key() / LicenseAsset) "
                         "but nothing opens an asset with it -- the asset is not "
                         "actually bound. Wire it into LicenseAsset::open() on a "
                         "sealed asset the feature genuinely needs (6.5)."))
    if consumes_asset and not has_negative_test:
        findings.append(("WARN", "(whole tree)", 0, "no-negative-test",
                         "no wrong/foreign-licence test detected. Add the section 10 "
                         "negative test: a bad licence must leave the feature "
                         "visibly broken, not merely 'invalid'."))
    if not binds_key and not consumes_asset:
        findings.append(("INFO", "(whole tree)", 0, "no-binding",
                         "no load-bearing binding found. If any paid feature loads "
                         "a mandatory asset, bind it with LicenseAsset (6.5) instead "
                         "of a bool gate."))

    order = {"ERROR": 0, "WARN": 1, "INFO": 2}
    findings.sort(key=lambda f: (order[f[0]], f[1], f[2]))
    errors = warns = 0
    for sev, path, n, rid, msg in findings:
        if sev == "ERROR":
            errors += 1
        elif sev == "WARN":
            warns += 1
        if args.quiet and sev != "ERROR":
            continue
        loc = f"{path}:{n}" if n else path
        print(f"{sev:5} [{rid}] {loc}\n      {msg}")

    print(f"\nrg_bind_lint: {errors} error(s), {warns} warning(s).")
    if errors:
        print("FAIL -- fix the ERROR findings before treating the integration as done.")
        return 1
    print("OK -- no boolean-regression errors. Still run the section 10 negative test.")
    return 0


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