#!/usr/bin/env python3
"""TraceFlow static file-flow scanner.

Reads source text only; never executes scanned PHP, JS, HTML, CSS, shell, or Python.
Supports literal paths plus common PHP __DIR__ and configured constant path expressions.
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from collections import deque
from pathlib import Path
from typing import Iterable

FILE_TYPES = {
    ".php": "php", ".phtml": "php", ".php3": "php", ".php4": "php", ".php5": "php",
    ".html": "html", ".htm": "html",
    ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript",
    ".css": "css",
    ".sh": "shell", ".bash": "shell",
    ".py": "python",
}

PHP_LITERAL = re.compile(
    r"""\b(?:include|include_once|require|require_once)\s*(?:\(\s*)?['\"]([^'\"]+)['\"]""",
    re.IGNORECASE,
)

PHP_DIR = re.compile(
    r"""\b(?:include|include_once|require|require_once)\s*(?:\(\s*)?__DIR__\s*\.\s*['\"]([^'\"]+)['\"]""",
    re.IGNORECASE,
)

PHP_CONSTANT = re.compile(
    r"""\b(?:include|include_once|require|require_once)\s*(?:\(\s*)?([A-Z][A-Z0-9_]*)\s*\.\s*['\"]([^'\"]+)['\"]""",
    re.IGNORECASE,
)

HTML_SCRIPT = re.compile(
    r"""<script\b[^>]*\bsrc\s*=\s*['\"]([^'\"]+)['\"]""",
    re.IGNORECASE,
)

HTML_LINK = re.compile(
    r"""<link\b[^>]*\bhref\s*=\s*['\"]([^'\"]+)['\"]""",
    re.IGNORECASE,
)

CSS_IMPORT = re.compile(
    r"""@import\s+(?:url\(\s*)?['\"]([^'\"]+)['\"]""",
    re.IGNORECASE,
)

JS_IMPORT = re.compile(
    r"""\bimport\s*(?:[^'\"()]*?\s+from\s+)?['\"]([^'\"]+)['\"]|\bimport\s*\(\s*['\"]([^'\"]+)['\"]\s*\)|\brequire\s*\(\s*['\"]([^'\"]+)['\"]\s*\)""",
    re.IGNORECASE,
)

SHELL_SOURCE = re.compile(
    r"""(?:^|[;\s])(?:source|\.)\s+['\"]?([^\s'\";]+)""",
    re.MULTILINE,
)

PYTHON_IMPORT = re.compile(
    r"""^\s*(?:from\s+([A-Za-z_]\w*(?:\.\w+)*)\s+import|import\s+([A-Za-z_]\w*(?:\.\w+)*))""",
    re.MULTILINE,
)

EXTERNAL_REFERENCE = re.compile(r"^(?:https?:)?//|^data:|^#", re.IGNORECASE)


def normalize_constant_paths(items: list[str]) -> dict[str, Path]:
    """Parse NAME=/path pairs supplied through --constant-path."""
    values: dict[str, Path] = {}
    for item in items:
        if "=" not in item:
            raise ValueError(f"Invalid --constant-path value: {item!r}; use NAME=/path")
        name, value = item.split("=", 1)
        name = name.strip()
        value = value.strip()
        if not re.fullmatch(r"[A-Z][A-Z0-9_]*", name):
            raise ValueError(f"Invalid PHP constant name: {name!r}")
        if not value:
            raise ValueError(f"Empty path for PHP constant: {name}")
        values[name] = Path(value).expanduser().resolve()
    return values


class FileFlowScanner:
    def __init__(self, root: Path, constant_paths: dict[str, Path] | None = None):
        self.root = root.resolve()
        self.constant_paths = constant_paths or {}
        self.nodes: dict[str, dict] = {}
        self.edges: list[dict] = []
        self.unresolved: list[dict] = []
        self.scanned: set[Path] = set()

    def file_type(self, path: Path) -> str:
        return FILE_TYPES.get(path.suffix.lower(), "unknown")

    def relative_id(self, path: Path) -> str:
        resolved = path.resolve()
        try:
            return resolved.relative_to(self.root).as_posix()
        except ValueError:
            return "[external]/" + resolved.name

    def add_node(self, path: Path) -> str:
        node_id = self.relative_id(path)
        self.nodes.setdefault(node_id, {
            "id": node_id,
            "type": self.file_type(path),
        })
        return node_id

    def is_external_reference(self, raw: str) -> bool:
        return bool(EXTERNAL_REFERENCE.match(raw.strip()))

    def candidate_paths(self, source: Path, raw: str, kind: str, constant: str | None) -> list[Path]:
        raw = raw.strip()
        candidates: list[Path] = []

        if kind == "php.include_dir":
            candidates.append(source.parent / raw)
            return candidates

        if kind == "php.include_constant":
            if constant and constant in self.constant_paths:
                candidates.append(self.constant_paths[constant] / raw.lstrip("/"))
            return candidates

        if raw.startswith("/"):
            candidates.append(self.root / raw.lstrip("/"))
        else:
            candidates.append(source.parent / raw)
            candidates.append(self.root / raw)

        return candidates

    def resolve_reference(
        self,
        source: Path,
        raw: str,
        kind: str,
        constant: str | None = None,
    ) -> Path | None:
        if not raw or self.is_external_reference(raw):
            return None

        for candidate in self.candidate_paths(source, raw, kind, constant):
            if candidate.is_file():
                return candidate.resolve()

            if candidate.suffix:
                continue

            for extension in (".php", ".js", ".mjs", ".css", ".sh", ".py"):
                with_extension = Path(str(candidate) + extension)
                if with_extension.is_file():
                    return with_extension.resolve()

            for index_name in ("index.php", "index.js", "index.html"):
                index_file = candidate / index_name
                if index_file.is_file():
                    return index_file.resolve()

        return None

    def add_reference(
        self,
        source: Path,
        raw: str,
        kind: str,
        constant: str | None = None,
    ) -> Path | None:
        source_id = self.add_node(source)
        target = self.resolve_reference(source, raw, kind, constant)

        if target is None:
            if raw and not self.is_external_reference(raw):
                record = {
                    "from": source_id,
                    "reference": raw,
                    "kind": kind,
                }
                if constant:
                    record["constant"] = constant
                    if constant not in self.constant_paths:
                        record["reason"] = "constant_path_not_configured"
                if record not in self.unresolved:
                    self.unresolved.append(record)
            return None

        target_id = self.add_node(target)
        edge = {
            "from": source_id,
            "to": target_id,
            "kind": kind,
        }
        if constant:
            edge["constant"] = constant

        if edge not in self.edges:
            self.edges.append(edge)

        return target

    def references(self, path: Path, text: str) -> Iterable[tuple[str, str, str | None]]:
        kind = self.file_type(path)

        if kind == "php":
            for raw in PHP_LITERAL.findall(text):
                yield raw, "php.include", None

            for raw in PHP_DIR.findall(text):
                yield raw, "php.include_dir", "__DIR__"

            for constant, raw in PHP_CONSTANT.findall(text):
                yield raw, "php.include_constant", constant

            for raw in HTML_SCRIPT.findall(text):
                yield raw, "html.script", None

            for raw in HTML_LINK.findall(text):
                yield raw, "html.stylesheet", None

        elif kind == "html":
            for raw in HTML_SCRIPT.findall(text):
                yield raw, "html.script", None

            for raw in HTML_LINK.findall(text):
                yield raw, "html.stylesheet", None

        elif kind == "css":
            for raw in CSS_IMPORT.findall(text):
                yield raw, "css.import", None

        elif kind == "javascript":
            for match in JS_IMPORT.findall(text):
                raw = next((value for value in match if value), "")
                if raw:
                    yield raw, "js.import", None

        elif kind == "shell":
            for raw in SHELL_SOURCE.findall(text):
                yield raw, "shell.source", None

        elif kind == "python":
            for from_module, import_module in PYTHON_IMPORT.findall(text):
                module = from_module or import_module
                if module:
                    yield module.replace(".", "/") + ".py", "python.import", None

    def scan(self, entries: list[Path]) -> dict:
        queue: deque[Path] = deque(entries)

        while queue:
            source = queue.popleft().resolve()

            if source in self.scanned or not source.is_file():
                continue

            self.scanned.add(source)
            self.add_node(source)

            try:
                text = source.read_text(encoding="utf-8", errors="replace")
            except OSError as error:
                self.unresolved.append({
                    "from": self.relative_id(source),
                    "reference": str(source),
                    "kind": "file.read",
                    "reason": str(error),
                })
                continue

            for raw, kind, constant in self.references(source, text):
                target = self.add_reference(source, raw, kind, constant)
                if target is not None and target not in self.scanned:
                    queue.append(target)

        return {
            "nodes": sorted(self.nodes.values(), key=lambda item: item["id"]),
            "edges": sorted(
                self.edges,
                key=lambda item: (item["from"], item["to"], item["kind"]),
            ),
            "unresolved": sorted(
                self.unresolved,
                key=lambda item: (item["from"], item["reference"], item["kind"]),
            ),
            "summary": {
                "node_count": len(self.nodes),
                "edge_count": len(self.edges),
                "unresolved_count": len(self.unresolved),
            },
        }


def make_path_display(root: Path, mode: str):
    def display(node_id: str) -> str:
        if node_id.startswith("[external]/"):
            return node_id

        if mode == "relative":
            return node_id

        if mode == "root":
            return "$ROOT/" + node_id

        return str((root / node_id).resolve())

    return display


def print_text_graph(graph: dict, display) -> None:
    for edge in graph["edges"]:
        suffix = ""
        if edge.get("constant"):
            suffix = f"; {edge['constant']}"
        print(
            f"{display(edge['from'])} "
            f"--[{edge['kind']}{suffix}]--> "
            f"{display(edge['to'])}"
        )

    for item in graph["unresolved"]:
        reason = f"; {item['reason']}" if item.get("reason") else ""
        constant = f"; {item['constant']}" if item.get("constant") else ""
        print(
            f"{display(item['from'])} "
            f"--[{item['kind']}; unresolved{constant}{reason}]--> "
            f"{item['reference']}"
        )

    print(json.dumps(graph["summary"]))


def print_tree(graph: dict, entry_ids: list[str], display) -> None:
    nodes = {node["id"]: node for node in graph["nodes"]}
    children: dict[str, list[dict]] = {}

    for edge in graph["edges"]:
        children.setdefault(edge["from"], []).append(edge)

    for source_id in children:
        children[source_id].sort(key=lambda edge: (edge["kind"], edge["to"]))

    def visit(
        node_id: str,
        prefix: str = "",
        is_last: bool = True,
        seen: set[str] | None = None,
        relation: str | None = None,
    ) -> None:
        seen = set() if seen is None else seen
        node_type = nodes.get(node_id, {}).get("type", "unknown")

        if relation is None:
            print(f"{display(node_id)} [{node_type}]")
        else:
            connector = "└─ " if is_last else "├─ "
            print(f"{prefix}{connector}{relation} -> {display(node_id)} [{node_type}]")

        if node_id in seen:
            if relation is not None:
                print(prefix + ("   " if is_last else "│  ") + "[seen]")
            else:
                print("[seen]")
            return

        next_seen = seen | {node_id}
        outgoing = children.get(node_id, [])

        for index, edge in enumerate(outgoing):
            child_is_last = index == len(outgoing) - 1
            child_prefix = prefix

            if relation is not None:
                child_prefix += "   " if is_last else "│  "

            visit(
                edge["to"],
                child_prefix,
                child_is_last,
                next_seen,
                edge["kind"],
            )

    for index, entry_id in enumerate(entry_ids):
        if index:
            print()
        visit(entry_id)


def resolve_entries(root: Path, raw_entries: list[str]) -> list[Path]:
    entries: list[Path] = []

    for item in raw_entries:
        path = Path(item).expanduser()
        entries.append(path.resolve() if path.is_absolute() else (root / path).resolve())

    return entries


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Non-executing TraceFlow static file-flow scanner"
    )

    subparsers = parser.add_subparsers(dest="command", required=True)

    def add_common_arguments(command_parser: argparse.ArgumentParser) -> None:
        command_parser.add_argument(
            "--root",
            default=".",
            help="Project root used to resolve local source references",
        )
        command_parser.add_argument(
            "--entry",
            action="append",
            required=True,
            help="Entry file relative to --root; specify more than once for multiple roots",
        )
        command_parser.add_argument(
            "--constant-path",
            action="append",
            default=[],
            metavar="NAME=PATH",
            help="Resolve a PHP constant path, e.g. SYSTEM_ROOT=/path/to/system",
        )
        command_parser.add_argument(
            "--paths",
            choices=["relative", "root", "absolute"],
            default="relative",
            help="Path style for text/tree output",
        )

    scan_parser = subparsers.add_parser(
        "scan",
        help="Generate a flat JSON or text graph",
    )
    add_common_arguments(scan_parser)
    scan_parser.add_argument(
        "--format",
        choices=["json", "text"],
        default="json",
        help="Output format when --output is not used",
    )
    scan_parser.add_argument(
        "--output",
        help="Write JSON graph to this filename",
    )

    tree_parser = subparsers.add_parser(
        "tree",
        help="Render hierarchy from one or more entry files",
    )
    add_common_arguments(tree_parser)

    args = parser.parse_args()

    root = Path(args.root).expanduser().resolve()
    if not root.is_dir():
        parser.error(f"--root is not a directory: {root}")

    try:
        constant_paths = normalize_constant_paths(args.constant_path)
    except ValueError as error:
        parser.error(str(error))

    entries = resolve_entries(root, args.entry)
    missing = [str(path) for path in entries if not path.is_file()]
    if missing:
        print("Missing entry file(s): " + ", ".join(missing), file=sys.stderr)
        return 2

    scanner = FileFlowScanner(root, constant_paths)
    graph = scanner.scan(entries)
    display = make_path_display(root, args.paths)

    if args.command == "tree":
        entry_ids = [scanner.relative_id(path) for path in entries]
        print_tree(graph, entry_ids, display)
        return 0

    if args.format == "text":
        print_text_graph(graph, display)
        return 0

    rendered = json.dumps(graph, indent=2, ensure_ascii=False) + "\n"
    if args.output:
        output = Path(args.output).expanduser()
        output.parent.mkdir(parents=True, exist_ok=True)
        output.write_text(rendered, encoding="utf-8")
    else:
        print(rendered, end="")

    return 0



def mask_comments(text):
    pattern = re.compile(
        r"('(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")"
        r"|(/\*[\s\S]*?\*/|//[^\n]*|\#[^\n]*)"
    )
    def replace(match):
        if match.group(1): return match.group(0)
        return ''.join('\n' if c=='\n' else ' ' for c in match.group(0))
    return pattern.sub(replace,text)

def split_expression(expr):
    parts=[]; begin=0; depth=0; quote=None; escape=False
    for i,c in enumerate(expr):
        if quote:
            if escape: escape=False
            elif c=='\\': escape=True
            elif c==quote: quote=None
        elif c in "'\"": quote=c
        elif c=='(': depth+=1
        elif c==')': depth-=1
        elif c=='.' and depth==0:
            parts.append(expr[begin:i]); begin=i+1
    parts.append(expr[begin:])
    return parts

ASSET_HELPERS={'app_url':'APP_PUBLIC_ROOT','versioned_system_asset_url':'PUBLIC_SYSTEM_ROOT'}

def evaluate_path(expr,source,values):
    expr=expr.strip()
    if expr.startswith('(') and expr.endswith(')'):
        return evaluate_path(expr[1:-1],source,values)
    parts=split_expression(expr)
    if len(parts)>1:
        results=[evaluate_path(p,source,values) for p in parts]
        return None if any(v is None for v in results) else ''.join(results)
    if len(expr)>=2 and expr[0] in "'\"" and expr[-1]==expr[0]:
        value=expr[1:-1]
        if expr[0]=='"':
            unknown=False
            def sub(m):
                nonlocal unknown
                key=m.group(0)
                if key not in values: unknown=True; return key
                return values[key]
            value=re.sub(r'\$[A-Za-z_]\w*',sub,value)
            if unknown: return None
        return value
    if expr=='__DIR__': return str(source.parent)
    if expr=='__FILE__': return str(source)
    if expr=='DIRECTORY_SEPARATOR': return '/'
    m=re.fullmatch(r'dirname\s*\((.*?)(?:,\s*(\d+))?\)',expr,re.S)
    if m:
        v=evaluate_path(m[1],source,values)
        if v is None: return None
        p=Path(v)
        for _ in range(int(m[2] or 1)): p=p.parent
        return str(p)
    m=re.fullmatch(r"(app_url|versioned_system_asset_url)\s*\(\s*['\"]([^'\"]+)['\"]\s*\)",expr,re.S)
    if m:
        base=values.get(ASSET_HELPERS[m[1]])
        if base: return str(Path(base)/m[2].lstrip('/'))
    return values.get(expr)

def parse_page_setup(expr,source,values):
    expr=expr.strip()
    if not (expr.startswith('[') and expr.endswith(']')):
        return False
    inner=expr[1:-1]
    parts=[]; begin=0; depth=0; quote=None; escape=False
    for i,c in enumerate(inner):
        if quote:
            if escape: escape=False
            elif c=='\\': escape=True
            elif c==quote: quote=None
        elif c in "'\"": quote=c
        elif c=='[': depth+=1
        elif c==']': depth-=1
        elif c==',' and depth==0:
            parts.append(inner[begin:i]); begin=i+1
    parts.append(inner[begin:])
    for part in parts:
        pair=re.match(r"\s*['\"]([^'\"]+)['\"]\s*=>\s*(.+?)\s*$",part,re.S)
        if pair:
            values["$pageSetup['"+pair[1]+"']"]=evaluate_path(pair[2],source,values)
    return True

def improved_references(self,path,text):
    kind=self.file_type(path)
    clean=mask_comments(text) if kind in ('php','javascript','css') else text
    clean=re.sub(r'<!--[\s\S]*?-->',lambda m:''.join('\n' if c=='\n' else ' ' for c in m[0]),clean)
    values={k:str(v) for k,v in self.constant_paths.items()}
    matches=[]
    if kind=='php':
        tokens=re.compile(r"(?P<include>\b(?:include_if_exists|include_once|require_once|include|require)\b)\s*(?P<iexpr>[^;]+);|(?:\bconst\s+)?(?P<assign>\$[A-Za-z_]\w*|[A-Z][A-Z0-9_]*)\s*=(?!=)\s*(?P<aexpr>[^;]+);|\bdefine\s*\(\s*['\"](?P<define>[A-Za-z_]\w*)['\"]\s*,\s*(?P<dexpr>[^;]+)\)\s*;",re.S)
        for m in tokens.finditer(clean):
            if m['include']:
                expression=m['iexpr'].strip()
                page_match=re.fullmatch(r"\(\s*\$pageSetup\[['\"][^'\"]+['\"]\]\s*\)",expression)
                if page_match:
                    key_match=re.search(r"\$pageSetup\[['\"]([^'\"]+)['\"]\]",expression)
                    if key_match is None:
                        matches.append((m.start(),expression,'php.include',None,False,expression))
                    else:
                        key=key_match.group(1)
                        value=values.get("$pageSetup['"+key+"']")
                        if value is None: value=values.get(key)
                        matches.append((m.start(),value or expression,'php.include_if_exists',None,value is not None,expression))
                else:
                    value=evaluate_path(expression,path,values)
                    matches.append((m.start(),value or expression,'php.include',None,value is not None,expression))
            else:
                name=m['assign'] or m['define']
                configured_value=values.get(name)
                if configured_value is not None: continue
                if name=='$pageSetup':
                    parse_page_setup(m['aexpr'],path,values)
                else:
                    values[name]=evaluate_path(m['aexpr'] or m['dexpr'],path,values)
    asset=re.compile(r"""<(?P<tag>script|link)\b[^>]*?\b(?:src|href)\s*=\s*['\"](?P<value><\?php\s+.*?\?>|<\?=.*?\?>|[^'\"]+)['\"]""",re.S|re.I)
    for m in asset.finditer(clean):
        value=m['value'].strip()
        if value.startswith('<'):
            php=re.sub(r'^<\?(?:php|=)?','',value); php=php.removesuffix('?>').strip()
            resolved=evaluate_path(php,path,values)
            matches.append((m.start(),resolved or php,'html.'+('script' if m['tag'].lower()=='script' else 'stylesheet'),None,resolved is not None,php))
        else:
            matches.append((m.start(),value,'html.'+('script' if m['tag'].lower()=='script' else 'stylesheet'),None,True,value))
    patterns=[]
    if kind=='css': patterns.append((CSS_IMPORT,'css.import'))
    if kind=='javascript': patterns.append((JS_IMPORT,'js.import'))
    if kind=='shell': patterns.append((SHELL_SOURCE,'shell.source'))
    if kind=='python': patterns.append((PYTHON_IMPORT,'python.import'))
    for pattern,relation in patterns:
        for m in pattern.finditer(clean):
            raw=next((v for v in m.groups() if v),'')
            if kind=='python': raw=raw.replace('.','/')+'.py'
            matches.append((m.start(),raw,relation,None,True,raw))
    for position,raw,relation,constant,resolved,expression in sorted(matches):
        self.current_line=text.count('\n',0,position)+1
        self.current_expression=expression
        self.dynamic_expression=not resolved
        yield raw,relation,constant

_original_add=FileFlowScanner.add_reference
def improved_add(self,source,raw,kind,constant=None):
    if self.dynamic_expression:
        self.unresolved.append({'from':self.add_node(source),'reference':raw,'kind':kind,'reason':'unsupported_dynamic_expression','line':self.current_line,'expression':self.current_expression})
        return None
    ec=len(self.edges); uc=len(self.unresolved)
    target=_original_add(self,source,raw,kind,constant)
    for record in self.edges[ec:]+self.unresolved[uc:]:
        record['line']=self.current_line; record['expression']=self.current_expression
    return target

_original_candidates=FileFlowScanner.candidate_paths
def improved_candidates(self,source,raw,kind,constant):
    if kind.startswith('html.'):
        raw=raw.split('?',1)[0].split('#',1)[0]
        if raw.startswith('/_system/'):
            base=self.constant_paths.get('PUBLIC_SYSTEM_ROOT')
            if base: return [base/raw.removeprefix('/_system/')]
        if Path(raw).is_absolute(): return [Path(raw)]
        if raw.startswith('/'):
            base=self.constant_paths.get('APP_PUBLIC_ROOT')
            if base: return [base/raw.lstrip('/')]
    if kind.startswith('php.') and Path(raw).is_absolute(): return [Path(raw)]
    return _original_candidates(self,source,raw,kind,constant)

def improved_id(self,path):
    try: return path.resolve().relative_to(self.root).as_posix()
    except ValueError: return str(path.resolve())

FileFlowScanner.references=improved_references
FileFlowScanner.add_reference=improved_add
FileFlowScanner.candidate_paths=improved_candidates
FileFlowScanner.relative_id=improved_id

def print_tree(graph,entry_ids,display):
    children={}
    for record in graph['edges']+graph['unresolved']:
        children.setdefault(record['from'],[]).append(record)
    for records in children.values(): records.sort(key=lambda r:r.get('line',0))
    def visit(node,prefix,ancestors):
        records=children.get(node,[])
        for i,record in enumerate(records):
            last=i==len(records)-1
            connector='└─ ' if last else '├─ '
            continuation='   ' if last else '│  '
            label=f"{record['kind']}; line {record.get('line','?')}"
            if 'to' not in record:
                print(prefix+connector+f"[{label}; {record.get('reason','unresolved')}] "+record['reference'])
                continue
            target=record['to']; cycle=target in ancestors
            print(prefix+connector+f'[{label}] '+display(target)+(' [cycle]' if cycle else ''))
            if not cycle: visit(target,prefix+continuation,ancestors|{target})
    for entry in entry_ids:
        print(display(entry)+' [static source-reference tree]')
        visit(entry,'',{entry})


if __name__ == "__main__":
    raise SystemExit(main())
