#!/usr/bin/env python3
"""Static release checks for the unified-login suite.

This intentionally checks security and integration invariants that ordinary PHP
linting cannot detect. It requires only Python's standard library.
"""
from __future__ import annotations

import hashlib
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
PORTAL = ROOT / "unified-user-portal"
STAFF = ROOT / "staff-compliance-portal"
AUDIT = ROOT / "store-audit-system"
if not AUDIT.is_dir():
    AUDIT = ROOT / "store-audit-system-v1.0.2"
LABELS = ROOT / "price-label-maker"
if not LABELS.is_dir():
    LABELS = ROOT / "website-price-label-maker-v1.1"

errors: list[str] = []
checks = 0


def check(condition: bool, message: str) -> None:
    global checks
    checks += 1
    if not condition:
        errors.append(message)


def text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except Exception as exc:  # pragma: no cover - release diagnostic
        errors.append(f"Could not read {path.relative_to(ROOT)}: {exc}")
        return ""


def php_text(folder: Path, exclude_client: bool = False) -> str:
    parts: list[str] = []
    for path in sorted(folder.rglob("*.php")):
        if exclude_client and path.name == "unified_auth_client.php":
            continue
        if "tests" in path.parts:
            continue
        parts.append(text(path))
    return "\n".join(parts)


for folder in (PORTAL, STAFF, AUDIT, LABELS):
    check(folder.is_dir(), f"Missing release folder: {folder.name}")

# All applications must run the exact same reviewed client implementation.
client_paths = [
    PORTAL / "client/unified_auth_client.php",
    STAFF / "unified_auth_client.php",
    AUDIT / "unified_auth_client.php",
    LABELS / "unified_auth_client.php",
]
client_hashes = []
for path in client_paths:
    check(path.is_file(), f"Missing shared authentication client: {path.relative_to(ROOT)}")
    if path.is_file():
        client_hashes.append(hashlib.sha256(path.read_bytes()).hexdigest())
check(len(set(client_hashes)) == 1, "Shared authentication client copies are not byte-for-byte identical")
client = text(client_paths[0])
for required in (
    "function ua_client_find_config_path",
    "refused a configuration file inside the public document root",
    "function ua_client_cookie_configuration_error",
    "function ua_client_session_row",
    "function ua_client_effective_permissions",
    "function ua_client_current_url",
    "Never reflect the request Host",
    "function ua_client_require_permission",
    "function ua_client_enforce_transport_security",
):
    check(required in client, f"Shared client is missing security invariant: {required}")
check("DOCUMENT_ROOT'] . '/unified-auth-config.php'" not in client, "Shared client contains a public-web-root configuration fallback")

# Protected fixed account levels.
catalog = text(PORTAL / "lib/catalog.php")
levels = re.findall(r"'([a-z_]+)'\s*=>\s*\['label'\s*=>\s*'([^']+)',\s*'rank'\s*=>\s*(\d+)\]", catalog)
expected_levels = [
    ("employee", "Employee", "10"),
    ("supervisor", "Supervisor", "20"),
    ("assistant_manager", "Assistant Manager", "30"),
    ("store_manager", "Store Manager", "40"),
    ("administrator", "Administrator", "50"),
    ("super_administrator", "Super Administrator", "60"),
]
check(levels[:6] == expected_levels, f"Fixed level catalogue differs from the required six levels: {levels[:6]!r}")

permission_records = re.findall(
    r"\['key'=>'((?:portal|staff|audit|labels)\.[a-z0-9_]+)','app'=>'([a-z_]+)'",
    catalog,
)
permission_keys = [key for key, _ in permission_records]
check(len(permission_keys) == len(set(permission_keys)), "Permission catalogue contains duplicate keys")
check(len(permission_keys) == 44, f"Expected 44 merged function permissions, found {len(permission_keys)}")
expected_prefix_app = {
    "portal": "user_portal",
    "staff": "staff_compliance",
    "audit": "store_audit",
    "labels": "price_labels",
}
for key, app in permission_records:
    check(expected_prefix_app[key.split(".", 1)[0]] == app, f"Permission {key} is assigned to the wrong app ({app})")

# Every permission literal used by an application must exist in the central catalogue.
source_sets = {
    "portal": php_text(PORTAL),
    "staff": php_text(STAFF),
    "audit": php_text(AUDIT),
    "labels": php_text(LABELS),
}
for prefix, source in source_sets.items():
    used = set(re.findall(rf"['\"]({prefix}\.[a-z0-9_]+)['\"]", source))
    if prefix == "audit":
        # Activity event names share the audit.* namespace; permission calls are
        # identifiable by the function or route-permission context.
        used = set(re.findall(r"['\"](audit\.(?:respond_to_actions|perform_standard_audits|perform_branch_self_audits|view_branch_reports|organisation_dashboard|assign_audits|peer_review_publish|manage_corrective_actions|manage_user_assignments|manage_branches|manage_templates|manage_schedules|manage_settings|view_activity_log|amend_published_audits|delete_audits))['\"]", source))
    if prefix == "labels":
        used.discard("labels.php")
    unknown = sorted(used - set(permission_keys))
    check(not unknown, f"{prefix} source uses permission keys missing from the catalogue: {unknown}")

# Correct application registration keys and installer/setup gates.
check("ua_client_bootstrap('staff_compliance'" in text(STAFF / "config.php"), "Staff Compliance uses the wrong central app key")
check("ua_client_bootstrap('store_audit'" in text(AUDIT / "app/bootstrap.php"), "Store Audit uses the wrong central app key")
check("ua_client_bootstrap('price_labels'" in text(LABELS / "bootstrap.php"), "Price Label Maker uses the wrong central app key")
check("ua_client_require_permission('staff.manage_settings'" in text(STAFF / "setup.php"), "Staff Compliance setup is not permission-gated")
check("ua_client_require_permission(\n    'audit.manage_settings'" in text(AUDIT / "install.php"), "Store Audit installer is not permission-gated")
check("require_permission('labels.manage_settings')" in text(LABELS / "setup.php"), "Price Label Maker setup is not permission-gated")

# No application may continue authenticating passwords locally.
for name, folder in (("Staff Compliance", STAFF), ("Store Audit", AUDIT), ("Price Label Maker", LABELS)):
    source = php_text(folder, exclude_client=True)
    check("password_verify(" not in source, f"{name} still verifies a local password")
    check("<input" not in source.lower() or "name=\"password\"" not in source.lower(), f"{name} exposes a local password form")

# Logout must be POST + CSRF everywhere.
logout_checks = [
    (STAFF / "logout.php", ["REQUEST_METHOD", "POST", "verify_csrf", "ua_client_logout"]),
    (LABELS / "logout.php", ["REQUEST_METHOD", "POST", "check_csrf", "ua_client_logout"]),
    (AUDIT / "app/controllers/AuthController.php", ["controller_logout", "request_method() !== 'POST'", "require_csrf", "logout_user"]),
    (PORTAL / "logout.php", ["ua_request_method()", "POST", "ua_require_csrf", "ua_client_logout"]),
]
for path, needles in logout_checks:
    body = text(path)
    for needle in needles:
        check(needle in body, f"{path.relative_to(ROOT)} logout is missing {needle!r}")
all_php = "\n".join(source_sets.values())
check(not re.search(r"href\s*=\s*['\"][^'\"]*logout", all_php, re.I), "A GET logout link remains in the release")

# Source-level direct action enforcement.
staff_admin = text(STAFF / "admin.php")
for action in (
    "add_user", "update_user", "reset_password", "bulk_upload_users",
    "add_document", "edit_document", "delete_document",
    "add_announcement", "update_announcement", "delete_announcement",
    "save_user_levels", "add_branch", "update_branch",
    "save_settings", "save_advanced_settings",
):
    check(f"'{action}'=>" in staff_admin, f"Staff Compliance admin action {action} lacks an allow/deny mapping")
check("isset($actionPermissions[$a])&&!has_permission($actionPermissions[$a])" in staff_admin, "Staff Compliance does not enforce its action permission map")

labels_admin = text(LABELS / "admin.php")
for action, permission in {
    "add_branch": "labels.manage_branches",
    "assign_user_branch": "labels.manage_user_assignments",
    "upload_background": "labels.manage_backgrounds",
    "process_review": "labels.manage_price_reviews",
    "delete_review": "labels.manage_price_reviews",
    "upload_csv": "labels.import_products",
}.items():
    block_pattern = re.compile(rf"\$action==='?{re.escape(action)}'?[^\n]*|{re.escape(action)}", re.S)
    check(action in labels_admin and permission in labels_admin, f"Price Label action {action} is not tied to {permission}")

helpers = text(AUDIT / "app/helpers.php")
check("function audit_route_permissions" in helpers, "Store Audit has no route permission map")
check("This route has no unified permission mapping and has been blocked for safety." in helpers, "Store Audit unmapped protected routes do not fail closed")
check("function require_role" in helpers and "audit_can" in helpers, "Store Audit route access does not use central permissions")

# Security headers, HTTPS and CSP must be present without broad inline/eval exceptions.
for path in (PORTAL / "lib/bootstrap.php", STAFF / "config.php", AUDIT / "app/bootstrap.php", LABELS / "bootstrap.php"):
    body = text(path)
    for needle in ("Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Cache-Control"):
        check(needle in body, f"{path.relative_to(ROOT)} is missing {needle}")
    check("unsafe-eval" not in body, f"{path.relative_to(ROOT)} allows unsafe-eval")
    check("unsafe-inline" not in body, f"{path.relative_to(ROOT)} allows unsafe-inline")

for path in (STAFF / "config.php", AUDIT / "app/bootstrap.php", LABELS / "bootstrap.php"):
    check("ua_client_enforce_transport_security();" in text(path), f"{path.relative_to(ROOT)} does not enforce HTTPS")

# Critical web-access controls. cPanel/Apache deployments must retain these.
protected_files = [
    PORTAL / ".htaccess", PORTAL / "client/.htaccess", PORTAL / "database/.htaccess", PORTAL / "lib/.htaccess", PORTAL / "tools/.htaccess", PORTAL / "tests/.htaccess",
    STAFF / ".htaccess", STAFF / "data/.htaccess", STAFF / "assets/.htaccess",
    AUDIT / ".htaccess", AUDIT / "storage/.htaccess", AUDIT / "assets/.htaccess",
    LABELS / ".htaccess", LABELS / "storage/.htaccess", LABELS / "uploads/backgrounds/.htaccess", LABELS / "uploads/data/.htaccess", LABELS / "uploads/logo/.htaccess",
]
for path in protected_files:
    body = text(path)
    check(path.is_file(), f"Missing access-control file: {path.relative_to(ROOT)}")
    check("Options -Indexes" in body, f"Directory listing is not disabled in {path.relative_to(ROOT)}")
for path in (PORTAL / "client/.htaccess", PORTAL / "database/.htaccess", PORTAL / "lib/.htaccess", PORTAL / "tools/.htaccess", PORTAL / "tests/.htaccess", STAFF / "data/.htaccess", AUDIT / "storage/.htaccess", LABELS / "storage/.htaccess", LABELS / "uploads/data/.htaccess"):
    check("Require all denied" in text(path), f"Sensitive directory is not denied: {path.relative_to(ROOT)}")
for path in (STAFF / "assets/.htaccess", AUDIT / "assets/.htaccess", LABELS / "uploads/backgrounds/.htaccess", LABELS / "uploads/logo/.htaccess"):
    body = text(path)
    check("RemoveHandler" in body and "php" in body.lower(), f"Script execution is not disabled in {path.relative_to(ROOT)}")

# Central authentication database and user-management safeguards.
schema = text(PORTAL / "database/schema.sql")
for needle in (
    "totp_last_used_step BIGINT UNSIGNED NULL",
    "auth_version INT UNSIGNED NOT NULL DEFAULT 1",
    "UNIQUE KEY uq_ua_users_username",
    "ua_user_permission_overrides",
    "ua_login_attempts",
    "ua_password_history",
    "ua_recovery_codes",
    "ua_audit_log",
):
    check(needle in schema, f"Central schema is missing {needle}")
security = text(PORTAL / "lib/security.php")
auth = text(PORTAL / "lib/auth_service.php")
for needle in (
    "PASSWORD_ARGON2ID", "ua_password_reused", "ua_consume_totp_code",
    "rowCount() === 1", "ua_safe_return_url", "ua_validate_application_url_scope",
):
    check(needle in security, f"Central security layer is missing {needle}")
for needle in (
    "ua_dummy_password_hash", "ua_login_is_rate_limited", "locked_until", "session_regenerate_id(true)",
    "ua_issue_auth_session", "ua_record_login_attempt", "ua_clear_login_failures_for_username",
    "password.change_rate_limited",
):
    check(needle in auth, f"Central authentication service is missing {needle}")
user_edit = text(PORTAL / "user-edit.php")
portal_bootstrap = text(PORTAL / "lib/bootstrap.php")
for needle in (
    "FOR UPDATE", "auth_version=auth_version+1",
    "final active Super Administrator", "ua_revoke_user_sessions",
):
    check(needle in user_edit, f"User editor is missing privilege/lockout safeguard: {needle}")
check("ua_can_manage_target($actor, $target)" in user_edit, "User editor does not invoke target-authority checks")
check("function ua_actor_access_dominates_target" in portal_bootstrap, "Portal is missing effective-access dominance checks")


# Installer input hardening and local PHP session state.
portal_install = text(PORTAL / "install.php")
audit_install = text(AUDIT / "install.php")
for path, body in ((PORTAL / "install.php", portal_install), (AUDIT / "install.php", audit_install)):
    check("FILTER_VALIDATE_INT" in body and "65535" in body, f"{path.relative_to(ROOT)} does not validate the database port server-side")
    check(not re.search(r'name=["\']db_pass["\'][^>]*\svalue=', body, re.I), f"{path.relative_to(ROOT)} repopulates the database password in HTML")
for path in (PORTAL / "lib/bootstrap.php", STAFF / "config.php", AUDIT / "app/bootstrap.php", AUDIT / "install.php", LABELS / "bootstrap.php"):
    check("session.use_trans_sid" in text(path), f"{path.relative_to(ROOT)} does not disable URL-based PHP session IDs")

# High-impact central actions must require recent step-up authentication.
for path in (PORTAL / "settings.php", PORTAL / "levels.php", PORTAL / "user-edit.php", PORTAL / "migration.php"):
    check("ua_require_recent_authentication" in text(path), f"{path.relative_to(ROOT)} does not require recent reauthentication")
check("totp_enabled" in text(PORTAL / "two-factor-setup.php") and "old" in text(PORTAL / "two-factor-setup.php").lower(), "Authenticator replacement does not require proof of the existing factor")

# Release documentation must match the delivered workflow.
required_docs = (
    "README.md", "CPANEL_DEPLOYMENT.md", "MIGRATION_GUIDE.md",
    "PERMISSION_MATRIX.md", "SECURITY_NOTES.md",
    "ACCEPTANCE_CHECKLIST.md", "RELEASE_NOTES.md", "TEST_REPORT.md",
)
for name in required_docs:
    check((ROOT / name).is_file(), f"Missing release documentation: {name}")
doc_text = "\n".join(text(ROOT / name) for name in required_docs if (ROOT / name).is_file())
for required_reference in ("CPANEL_DEPLOYMENT.md", "MIGRATION_GUIDE.md", "PERMISSION_MATRIX.md", "ACCEPTANCE_CHECKLIST.md"):
    check(required_reference in doc_text, f"Release documentation does not reference {required_reference}")

# Release must not contain source backups or a private central secret file.
for path in ROOT.rglob("*"):
    if not path.is_file() or ".git" in path.parts:
        continue
    name = path.name.lower()
    forbidden = (
        name.endswith((".pre-unified", ".bak", ".backup", ".old", ".orig", ".save", ".swp", ".pyc"))
        or name == "unified-auth-config.php"
        or "__pycache__" in path.parts
    )
    check(not forbidden, f"Forbidden backup/private artifact in release: {path.relative_to(ROOT)}")

if errors:
    print(f"RELEASE VALIDATION FAILED: {len(errors)} failure(s) across {checks} checks")
    for index, error in enumerate(errors, 1):
        print(f"{index:02d}. {error}")
    sys.exit(1)

print(f"RELEASE VALIDATION PASSED: {checks} static integration/security checks, 44 function permissions, 6 protected account levels.")
