#!/usr/bin/env python3
"""Dependency-free structural verification for the source tree.

This does not replace Composer, migrations, or PHPUnit. It catches missing internal
classes, route/controller methods, named routes, and Blade templates before release.
"""
from __future__ import annotations

import json
import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
errors: list[str] = []


def snake_case(name: str) -> str:
    return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()


def table_candidates(class_name: str) -> list[str]:
    base = snake_case(class_name)
    irregular = {
        "company": "companies",
        "party": "parties",
        "proof_of_delivery": "proofs_of_delivery",
        "approval_history": "approval_histories",
    }
    candidates = [irregular[base]] if base in irregular else []
    if base.endswith("y"):
        candidates.append(base[:-1] + "ies")
    elif base.endswith(("s", "x", "z", "ch", "sh")):
        candidates.append(base + "es")
    candidates.append(base + "s")
    return list(dict.fromkeys(candidates))


def matching_brace(text: str, opening: int) -> int:
    depth = 0
    quote: str | None = None
    escaped = False
    i = opening
    while i < len(text):
        char = text[i]
        if quote:
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == quote:
                quote = None
        else:
            if char in ("'", '"'):
                quote = char
            elif char == "{":
                depth += 1
            elif char == "}":
                depth -= 1
                if depth == 0:
                    return i
        i += 1
    return -1


for json_file in [ROOT / "composer.json", ROOT / "package.json"]:
    try:
        json.loads(json_file.read_text())
    except Exception as exc:  # noqa: BLE001
        errors.append(f"Invalid JSON {json_file.relative_to(ROOT)}: {exc}")

php_files = list((ROOT / "app").rglob("*.php")) + list((ROOT / "bootstrap").rglob("*.php")) + list((ROOT / "config").rglob("*.php")) + list((ROOT / "database").rglob("*.php")) + list((ROOT / "routes").rglob("*.php")) + list((ROOT / "tests").rglob("*.php"))
for php_file in php_files:
    result = subprocess.run(["php", "-l", str(php_file)], capture_output=True, text=True)
    if result.returncode:
        errors.append(f"PHP lint failed: {php_file.relative_to(ROOT)}: {result.stdout}{result.stderr}")

class_files: dict[str, Path] = {}
for php_file in (ROOT / "app").rglob("*.php"):
    source = php_file.read_text(errors="ignore")
    namespace = re.search(r"namespace\s+([^;]+);", source)
    declaration = re.search(r"\b(?:final\s+|abstract\s+)?(?:class|interface|trait|enum)\s+(\w+)", source)
    if namespace and declaration:
        class_files[f"{namespace.group(1)}\\{declaration.group(1)}"] = php_file

for php_file in php_files:
    source = php_file.read_text(errors="ignore")
    for class_name in re.findall(r"\buse\s+(App\\[^;{]+);", source):
        class_name = class_name.split(" as ", 1)[0].strip()
        if class_name not in class_files:
            errors.append(f"Missing internal class {class_name} used by {php_file.relative_to(ROOT)}")


# Compare model fillable attributes with migration-defined columns where the table can be resolved.
migration_tables: dict[str, set[str]] = {}
for migration_file in (ROOT / "database/migrations").glob("*.php"):
    source = migration_file.read_text(errors="ignore")
    pattern = re.compile(r"Schema::(?:create|table)\(\s*['\"]([^'\"]+)['\"],\s*function\s*\([^)]*\)\s*(?::\s*void\s*)?\{")
    for match in pattern.finditer(source):
        table_name = match.group(1)
        opening = match.end() - 1
        closing = matching_brace(source, opening)
        if closing < 0:
            errors.append(f"Unmatched migration closure in {migration_file.relative_to(ROOT)}")
            continue
        migration_block = source[opening:closing + 1]
        columns = migration_tables.setdefault(table_name, set())
        if "Schema::create" in source[max(0, match.start() - 20):match.start() + 20]:
            columns.add("id")
        for column_match in re.finditer(r"\$\w+->(\w+)\(\s*['\"]([^'\"]+)['\"]", migration_block):
            method, column = column_match.groups()
            if method in {"index", "unique", "primary", "foreign", "dropForeign", "dropColumn"}:
                continue
            if method in {"morphs", "nullableMorphs", "uuidMorphs", "nullableUuidMorphs", "ulidMorphs", "nullableUlidMorphs"}:
                columns.update({f"{column}_type", f"{column}_id"})
            else:
                columns.add(column)
        if re.search(r"->id\(\s*\)", migration_block):
            columns.add("id")
        if re.search(r"->timestamps\(\s*\)", migration_block):
            columns.update({"created_at", "updated_at"})
        if re.search(r"->softDeletes\(\s*\)", migration_block):
            columns.add("deleted_at")
        if re.search(r"->rememberToken\(\s*\)", migration_block):
            columns.add("remember_token")

checked_model_tables = 0
for model_file in (ROOT / "app/Models").glob("*.php"):
    source = model_file.read_text(errors="ignore")
    class_match = re.search(r"\bfinal\s+class\s+(\w+)", source)
    fillable_match = re.search(r"protected\s+\$fillable\s*=\s*\[(.*?)\];", source, re.S)
    if not class_match or not fillable_match:
        continue
    explicit_table = re.search(r"protected\s+\$table\s*=\s*['\"]([^'\"]+)", source)
    candidates = [explicit_table.group(1)] if explicit_table else table_candidates(class_match.group(1))
    table_name = next((candidate for candidate in candidates if candidate in migration_tables), None)
    if not table_name:
        continue
    checked_model_tables += 1
    fillable = set(re.findall(r"['\"]([^'\"]+)['\"]", fillable_match.group(1)))
    missing_columns = sorted(fillable - migration_tables[table_name])
    if missing_columns:
        errors.append(
            f"Model {class_match.group(1)} fillable columns missing from {table_name}: {', '.join(missing_columns)}"
        )

route_file = ROOT / "routes/web.php"
routes_source = route_file.read_text()
use_map = {short: full for full, short in re.findall(r"use\s+([^;]+\\(\w+));", routes_source)}

# Named route prefixes and controller contexts.
prefix_intervals: list[tuple[int, int, str]] = []
controller_intervals: list[tuple[int, int, str]] = []
for match in re.finditer(r"Route::prefix\([^;]+?\)(.*?)\->group\(function\s*\([^)]*\)\s*:\s*void\s*\{", routes_source, re.S):
    chain = match.group(0)
    opening = match.end() - 1
    closing = matching_brace(routes_source, opening)
    if closing < 0:
        errors.append("Unmatched route group brace")
        continue
    name_match = re.search(r"->name\(['\"]([^'\"]+)['\"]\)", chain)
    controller_match = re.search(r"->controller\((\w+)::class\)", chain)
    if name_match:
        prefix_intervals.append((opening, closing, name_match.group(1)))
    if controller_match:
        controller_intervals.append((opening, closing, controller_match.group(1)))

known_routes: set[str] = set()
for match in re.finditer(r"->name\(['\"]([^'\"]+)['\"]\)", routes_source):
    name = match.group(1)
    if name.endswith("."):
        continue
    prefixes = [prefix for start, end, prefix in prefix_intervals if start < match.start() < end]
    known_routes.add("".join(prefixes) + name)

# Laravel resource names used by this application.
for resource, controller, options in re.findall(r"Route::resource\(['\"]([^'\"]+)['\"],\s*(\w+)::class\)([^;]*);", routes_source):
    actions = {"index", "create", "store", "show", "edit", "update", "destroy"}
    except_match = re.search(r"->except\(([^)]+)\)", options)
    if except_match:
        actions -= set(re.findall(r"['\"]([^'\"]+)['\"]", except_match.group(1)))
    for action in actions:
        known_routes.add(f"{resource}.{action}")

route_uses: set[str] = set()
for source_file in list((ROOT / "resources/views").rglob("*.blade.php")) + list((ROOT / "app").rglob("*.php")):
    source = source_file.read_text(errors="ignore")
    route_uses.update(re.findall(r"route\(\s*['\"]([^'\"]+)['\"]", source))

# Dynamic workflow component route names are validated through their concrete route groups.
missing_routes = sorted(route_uses - known_routes)
for route_name in missing_routes:
    errors.append(f"Named route used but not declared: {route_name}")

# Validate controller methods referenced by routes.
methods_by_class: dict[str, set[str]] = {}
for short_name, full_name in use_map.items():
    path = class_files.get(full_name)
    if path:
        methods_by_class[short_name] = set(re.findall(r"public\s+function\s+(\w+)\s*\(", path.read_text(errors="ignore")))

for match in re.finditer(r"Route::(?:get|post|put|patch|delete)\(\s*[^,]+,\s*['\"](\w+)['\"]\)\s*->name", routes_source, re.S):
    method = match.group(1)
    contexts = [(start, end, controller) for start, end, controller in controller_intervals if start < match.start() < end]
    if contexts:
        controller = min(contexts, key=lambda item: item[1] - item[0])[2]
        if method not in methods_by_class.get(controller, set()):
            errors.append(f"Route calls missing method {controller}::{method}")

for controller, method in re.findall(r"\[(\w+)::class,\s*['\"](\w+)['\"]\]", routes_source):
    if method not in methods_by_class.get(controller, set()):
        errors.append(f"Route calls missing method {controller}::{method}")

for resource, controller, options in re.findall(r"Route::resource\(['\"]([^'\"]+)['\"],\s*(\w+)::class\)([^;]*);", routes_source):
    actions = {"index", "create", "store", "show", "edit", "update", "destroy"}
    except_match = re.search(r"->except\(([^)]+)\)", options)
    if except_match:
        actions -= set(re.findall(r"['\"]([^'\"]+)['\"]", except_match.group(1)))
    for action in actions:
        if action not in methods_by_class.get(controller, set()):
            errors.append(f"Resource {resource} calls missing method {controller}::{action}")

blade_root = ROOT / "resources/views"
def blade_exists(name: str) -> bool:
    return (blade_root / (name.replace(".", "/") + ".blade.php")).exists()

for source_file in list((ROOT / "resources/views").rglob("*.blade.php")) + list((ROOT / "app").rglob("*.php")):
    source = source_file.read_text(errors="ignore")
    for name in re.findall(r"(?:view|@extends|@include)\(\s*['\"]([^'\"]+)['\"]", source):
        if "::" in name or "$" in name:
            continue
        if not blade_exists(name):
            errors.append(f"Missing Blade view {name} referenced by {source_file.relative_to(ROOT)}")

if errors:
    print("STATIC VERIFICATION FAILED")
    for error in sorted(set(errors)):
        print(f"- {error}")
    sys.exit(1)

print(f"STATIC VERIFICATION PASSED: {len(php_files)} PHP files, {len(class_files)} internal classes, {checked_model_tables} model/schema checks, {len(known_routes)} named routes, {len(route_uses)} route references.")
