#!/usr/bin/env python3
"""
static_flow.py — Static file-reference scanner.

Scans a directory tree for PHP, HTML, bash/sh, Python, CSS, and JS files,
extracts file-to-file references (include/require, <script src>, source,
import, @import, etc.), and prints/exports a reference hierarchy.

Usage:
    python3 static_flow.py <root_dir> [--root-file path] [--json out.json] [--reverse]

    --root-file   Print the tree starting from this file (repeatable).
                   If omitted, prints a tree for every file that nothing
                   else references (i.e. likely entry points).
    --json        Dump the reference graph as JSON (scoped to --root-file's
                   reachable subtree if given, otherwise the whole scan;
                   use --json-full to force the whole scan either way).
    --reverse     Print "who references me" instead of "what I reference".
    --debug       Print every matched line (file:line, source text, and
                   what it resolved to or why it didn't) to stderr.
                   Scoped to --root-file when given — other scanned files
                   still get walked (needed to resolve targets), but only
                   the file(s) you named print debug lines.

Limitations:
    Dynamic references built from variables (e.g. `include $page . '.php';`)
    cannot be resolved statically. These are recorded as UNRESOLVED with the
    raw expression, not silently dropped or guessed.
"""

import argparse
import json
import os
import re
import sys

EXT_LANG = {
    ".php": "php", ".phtml": "php",
    ".html": "html", ".htm": "html",
    ".sh": "bash", ".bash": "bash",
    ".py": "python",
    ".css": "css",
    ".js": "js", ".mjs": "js", ".cjs": "js",
}

# Which pattern categories (see PATTERNS below) apply to each extension.
# PHP files are almost always PHP logic wrapped around HTML output — they
# get the "php" include/require patterns AND the html/css patterns for
# whatever markup they emit (<link>, <script src>, inline <style> @import,
# inline <script type="module"> imports, etc). Plain .html gets the same
# minus the PHP-only include/require category.
EXT_PATTERN_CATEGORIES = {
    ".php": ["php", "html", "css", "js"],
    ".phtml": ["php", "html", "css", "js"],
    ".html": ["html", "css", "js"],
    ".htm": ["html", "css", "js"],
    ".sh": ["bash"],
    ".bash": ["bash"],
    ".py": ["python"],
    ".css": ["css"],
    ".js": ["js"], ".mjs": ["js"], ".cjs": ["js"],
}

# Each pattern captures either a literal path/module (group 1),
# or flags the whole match as dynamic/unresolved (handled via DYNAMIC_HINTS).
PATTERNS = {
    "html": [
        re.compile(r"""<script[^>]+src=['"]([^'"]+)['"]"""),
        re.compile(r"""<link[^>]+href=['"]([^'"]+)['"]"""),
        re.compile(r"""<img[^>]+src=['"]([^'"]+)['"]"""),
        re.compile(r"""<iframe[^>]+src=['"]([^'"]+)['"]"""),
        re.compile(r"""<a[^>]+href=['"]([^'"]+)['"]"""),
        re.compile(r"""<form[^>]+action=['"]([^'"]+)['"]"""),
    ],
    "bash": [
        re.compile(r"""^\s*(?:source|\.)\s+['"]?([^\s'"]+)['"]?""", re.MULTILINE),
        re.compile(r"""^\s*(?:bash|sh)\s+['"]?([^\s'"]+\.sh)['"]?""", re.MULTILINE),
    ],
    "python": [
        re.compile(r"""^\s*import\s+([\w\.]+)""", re.MULTILINE),
        re.compile(r"""^\s*from\s+([\w\.]+)\s+import""", re.MULTILINE),
    ],
    "css": [
        re.compile(r"""@import\s+(?:url\()?['"]?([^'")\s;]+)['"]?\)?"""),
        re.compile(r"""url\(\s*['"]?([^'")\s]+)['"]?\s*\)"""),
    ],
    "js": [
        re.compile(r"""import\s+(?:[\w{}\*\s,]+\s+from\s+)?['"]([^'"]+)['"]"""),
        re.compile(r"""require\(\s*['"]([^'"]+)['"]\s*\)"""),
    ],
}

# PHP is handled as whole statements rather than a single regex (see
# extract_refs): each include/require/... statement is located first, then
# a quoted literal is looked for *inside* that statement. If none is found,
# the statement is genuinely dynamic (e.g. `include $page . '.php';`) — not
# just "no quote right after the keyword", which used to misfire on
# `include APP_ROOT . "/navbar.php";` even though that resolves fine.
PHP_STATEMENT_RE = re.compile(r"""\b(?:include|include_once|require|require_once)\b[^;\n]*;""")
QUOTE_RE = re.compile(r"""['"]([^'"]+)['"]""")

# Coarse fallback for languages where a full statement-parse (like PHP gets)
# isn't worth the complexity — just flags that *something* dynamic exists
# in the file, without pinpointing every occurrence.
DYNAMIC_HINT = {
    "bash": re.compile(r"""^\s*(?:source|\.)\s+\$""", re.MULTILINE),
    "python": re.compile(r"""importlib\.import_module\("""),
    "js": re.compile(r"""import\(\s*[^'")]"""),
}

EXCLUDE_DIRS = {".git", "node_modules", "vendor", "__pycache__", ".venv"}

# Not real file references — placeholder/behavioral hrefs, not paths to resolve.
IGNORE_TARGETS = ("#", "javascript:", "mailto:", "tel:")


def scan_files(root):
    files = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
        for fn in filenames:
            ext = os.path.splitext(fn)[1].lower()
            if ext in EXT_LANG:
                files.append(os.path.join(dirpath, fn))
    return files


def module_to_candidates(module):
    # crude python module->path guess: a.b.c -> a/b/c.py or a/b/c/__init__.py
    parts = module.split(".")
    return [os.path.join(*parts) + ".py", os.path.join(*parts, "__init__.py")]


def resolve(ref, referencing_file, all_files_set, root):
    if ref.startswith(("http://", "https://", "//", "data:")):
        return None  # external, not a local file
    base_dir = os.path.dirname(referencing_file)
    candidates = [
        os.path.normpath(os.path.join(base_dir, ref)),
        # Constant-built paths like APP_ROOT . "/navbar.php" leave a leading
        # slash on the captured literal even though it's meant to be relative
        # to the referencing file's own directory — try that too.
        os.path.normpath(os.path.join(base_dir, ref.lstrip("/"))),
        os.path.normpath(os.path.join(root, ref.lstrip("/"))),
    ]
    if ref.startswith("/"):
        candidates.append(os.path.normpath(ref))  # true absolute path
    for c in candidates:
        if c in all_files_set:
            return c
    return None


def extract_refs(path, categories, root, all_files_set, debug=False):
    with open(path, "r", errors="ignore") as f:
        text = f.read()

    # Every reference is collected as (position, category, raw_text, status)
    # first, THEN sorted by position — so the final resolved/unresolved/
    # external lists reflect the order things actually appear in the file,
    # not the order the pattern categories happen to run in.
    matches = []  # status is a resolved path string, or "UNRESOLVED"/"EXTERNAL"/"DYNAMIC"/"SKIPPED"

    for cat in categories:
        if cat == "php":
            for sm in PHP_STATEMENT_RE.finditer(text):
                stmt = sm.group(0)
                qm = QUOTE_RE.search(stmt)
                if qm:
                    raw = qm.group(1)
                    pos = sm.start() + qm.start()
                    r = resolve(raw, path, all_files_set, root)
                    matches.append((pos, cat, raw, r if r else "UNRESOLVED"))
                else:
                    matches.append((sm.start(), cat, stmt.strip(), "DYNAMIC"))
            continue

        for pat in PATTERNS.get(cat, []):
            for m in pat.finditer(text):
                raw = m.group(1)
                pos = m.start()
                if raw == "" or raw.startswith(IGNORE_TARGETS):
                    continue  # "#", javascript:, mailto:, tel: — not a file reference at all
                if cat == "python":
                    found = False
                    for cand in module_to_candidates(raw):
                        r = resolve(cand, path, all_files_set, root)
                        if r:
                            matches.append((pos, cat, raw, r))
                            found = True
                            break
                    if not found:
                        matches.append((pos, cat, raw, "SKIPPED" if "." in raw else "UNRESOLVED"))
                elif raw.startswith(("http://", "https://", "//", "data:")):
                    matches.append((pos, cat, raw, "EXTERNAL"))
                else:
                    r = resolve(raw, path, all_files_set, root)
                    matches.append((pos, cat, raw, r if r else "UNRESOLVED"))

        hint = DYNAMIC_HINT.get(cat)
        if hint:
            hm = hint.search(text)
            if hm:
                matches.append((hm.start(), cat, "<dynamic reference>", "DYNAMIC"))

    matches.sort(key=lambda x: x[0])

    resolved, unresolved, external, calls = [], [], [], []
    seen = set()
    lines = text.splitlines()
    for pos, cat, raw, status in matches:
        line_no = text.count("\n", 0, pos) + 1

        if debug:
            line_text = lines[line_no - 1].strip() if line_no - 1 < len(lines) else ""
            label = status if status in ("EXTERNAL", "UNRESOLVED", "DYNAMIC", "SKIPPED") else os.path.relpath(status, root)
            print(f"  [{cat}] {os.path.relpath(path, root)}:{line_no}: {line_text}", file=sys.stderr)
            print(f"        \"{raw}\" -> {label}", file=sys.stderr)

        if status == "EXTERNAL":
            calls.append({"type": "external", "raw": raw, "line": line_no})
            external.append(raw)
        elif status == "UNRESOLVED":
            calls.append({"type": "unresolved", "raw": raw, "line": line_no})
            unresolved.append(raw)
        elif status == "DYNAMIC":
            note = f"<{cat}: dynamic — {raw}>"
            calls.append({"type": "unresolved", "raw": note, "line": line_no})
            unresolved.append(note)
        elif status == "SKIPPED":
            continue  # stdlib/3rd-party python import, not in this scan
        else:
            calls.append({"type": "resolved", "raw": raw, "target": status, "line": line_no})
            if status not in seen:
                resolved.append(status)
                seen.add(status)

    return resolved, unresolved, external, calls


def build_graph(root, debug=False, debug_only=None):
    """debug_only: if given, a set of normalized file paths — debug output
    (stderr) is limited to just those files, even though every file in the
    tree still has to be scanned to build the graph's cross-references."""
    files = scan_files(root)
    all_files_set = set(os.path.normpath(f) for f in files)
    graph = {}
    for f in files:
        norm_f = os.path.normpath(f)
        ext = os.path.splitext(f)[1].lower()
        lang = EXT_LANG[ext]
        categories = EXT_PATTERN_CATEGORIES.get(ext, [lang])
        file_debug = debug and (debug_only is None or norm_f in debug_only)
        resolved, unresolved, external, calls = extract_refs(f, categories, root, all_files_set, debug=file_debug)
        graph[norm_f] = {
            "refs": resolved,        # unique resolved targets, used for graph traversal
            "calls": calls,          # every reference IN FILE ORDER, each typed + with its line number
            "lang": lang,
        }
    return graph


def collect_subgraph(graph, roots):
    """Return only the nodes reachable from the given root files."""
    reachable = set()
    stack = list(roots)
    while stack:
        node = stack.pop()
        if node in reachable or node not in graph:
            continue
        reachable.add(node)
        stack.extend(graph[node]["refs"])
    return {n: graph[n] for n in reachable}


def print_tree(graph, node, prefix="", visited=None, reverse=False):
    if visited is None:
        visited = set()
    print(prefix + os.path.relpath(node))
    if node in visited:
        print(prefix + "  ↳ (cycle, stopping)")
        return
    visited = visited | {node}

    if reverse:
        children = [n for n, data in graph.items() if node in data["refs"]]
        for child in children:
            print_tree(graph, child, prefix + "    ", visited, reverse)
        return

    for entry in graph.get(node, {}).get("calls", []):
        line = entry.get("line")
        loc = f" (line {line})" if line else ""
        if entry["type"] == "resolved":
            print_tree(graph, entry["target"], prefix + "    ", visited, reverse)
        elif entry["type"] == "unresolved":
            print(prefix + f"    ⚠ unresolved{loc}: " + entry["raw"])
        elif entry["type"] == "external":
            print(prefix + f"    ↗ external{loc}: " + entry["raw"])


def find_entry_points(graph):
    referenced = set()
    for data in graph.values():
        referenced.update(data["refs"])
    return [f for f in graph if f not in referenced]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("root")
    ap.add_argument("--root-file", action="append", default=[])
    ap.add_argument("--json")
    ap.add_argument("--json-full", action="store_true",
                     help="Dump the whole scanned graph to JSON, not just the "
                          "subtree(s) reachable from --root-file.")
    ap.add_argument("--reverse", action="store_true")
    ap.add_argument("--debug", action="store_true",
                     help="Print each matched line (file:line, source text, "
                          "and what it resolved to) to stderr while scanning.")
    args = ap.parse_args()

    debug_only = set(os.path.normpath(r) for r in args.root_file) if args.root_file else None
    graph = build_graph(args.root, debug=args.debug, debug_only=debug_only)

    roots = [os.path.normpath(r) for r in args.root_file] if args.root_file else find_entry_points(graph)
    if not roots:
        roots = list(graph.keys())

    if args.json:
        if args.root_file and not args.json_full:
            dump = collect_subgraph(graph, roots)
        else:
            dump = graph
        with open(args.json, "w") as f:
            json.dump(dump, f, indent=2)
        print(f"Wrote graph JSON to {args.json} ({len(dump)} file(s))", file=sys.stderr)

    for r in roots:
        if r not in graph:
            print(f"(not found in scan: {r})", file=sys.stderr)
            continue
        print_tree(graph, r, reverse=args.reverse)
        print()


if __name__ == "__main__":
    main()
