#!/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 str(resolved)

    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.get("line", 0)),
            ),
            "unresolved": sorted(
                self.unresolved,
                key=lambda item: (item["from"], item.get("line", 0)),
            ),
            "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, depth, quote, escape = [], 0, 0, None, 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


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 substitute(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*', substitute, 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 '/'
    match = re.fullmatch(r'dirname\s*\((.*?)(?:,\s*(\d+))?\)', expr, re.S)
    if match:
        value = evaluate_path(match[1], source, values)
        if value is None:
            return None
        path = Path(value)
        for _ in range(int(match[2] or 1)):
            path = path.parent
        return str(path)
    return values.get(expr)


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)
    matches = []
    values = {k: str(v) for k, v in self.constant_paths.items()}
    if kind == 'php':
        tokens = re.compile(
            r"(?P<include>\b(?:include_once|require_once|include|require)\b)"
            r"\s*(?P<iexpr>[^;]+);"
            r"|(?:\bconst\s+)?(?P<assign>\$[A-Za-z_]\w*|[A-Z][A-Z0-9_]*)"
            r"\s*=(?!=)\s*(?P<aexpr>[^;]+);"
            r"|\bdefine\s*\(\s*['\"](?P<define>[A-Za-z_]\w*)['\"]"
            r"\s*,\s*(?P<dexpr>[^;]+)\)\s*;", re.S)
        for m in tokens.finditer(clean):
            if m['include']:
                expression = m['iexpr'].strip()
                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']
                value = evaluate_path(m['aexpr'] or m['dexpr'], path, values)
                if name not in self.constant_paths:
                    if value is not None:
                        values[name] = value
                    else:
                        values.pop(name, None)
    patterns = []
    if kind in ('php', 'html'):
        patterns += [(HTML_SCRIPT, 'html.script'), (HTML_LINK, 'html.stylesheet')]
    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_reference = FileFlowScanner.add_reference

def improved_add_reference(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,
        })
        return None
    edge_count = len(self.edges)
    unresolved_count = len(self.unresolved)
    target = _original_add_reference(self, source, raw, kind, constant)
    for record in self.edges[edge_count:] + self.unresolved[unresolved_count:]:
        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('php.') and Path(raw).is_absolute():
        return [Path(raw)]
    if kind.startswith('html.') or kind.startswith('css.') or kind.startswith('js.'):
        raw = raw.split('?', 1)[0].split('#', 1)[0]
    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_reference
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}; 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())
