<?php
/**
 * Session-based authentication helpers.
 *
 * Layout in $_SESSION:
 *   $_SESSION['uid']            int      - user id (set on successful login)
 *   $_SESSION['user_type']      string   - 'internal' or 'client'
 *   $_SESSION['client_id']      ?int     - non-null for client users only
 *   $_SESSION['must_change_pw'] bool     - if true, user must rotate before doing anything else
 *   $_SESSION['login_time']     int      - epoch seconds
 *   $_SESSION['last_activity']  int      - rolling timeout marker
 *   $_SESSION['ip']             string   - locked to login IP for hijack protection
 *   $_SESSION['ua_hash']        string   - hash of user-agent at login
 */

function start_session(): void
{
    if (session_status() === PHP_SESSION_ACTIVE) return;

    $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
    session_set_cookie_params([
        'lifetime' => 0,
        'path'     => '/',
        'secure'   => $secure,
        'httponly' => true,
        'samesite' => 'Strict',
    ]);
    session_name(SESSION_NAME);
    session_start();
}

function current_user(): ?array
{
    start_session();
    if (empty($_SESSION['uid'])) return null;
    static $cached = null;
    if ($cached !== null && $cached['id'] === $_SESSION['uid']) return $cached;
    $stmt = db()->prepare(
        'SELECT id, name, email, user_type, client_id, status,
                must_change_password, last_login_at, last_login_ip
         FROM users WHERE id = ? LIMIT 1'
    );
    $stmt->execute([$_SESSION['uid']]);
    $cached = $stmt->fetch() ?: null;
    return $cached;
}

function is_logged_in(): bool
{
    return current_user() !== null;
}

/** Hard-stop. Use at the top of every protected page after start_session(). */
function require_login(string $type = 'internal'): array
{
    start_session();
    $user = current_user();

    // AJAX/JSON callers (the coding chart's Save, the keepalive ping, any XHR) must get a
    // machine-readable 401 — NOT an HTML login redirect, which the client can't parse and
    // reports as the confusing "Session expired". This lets the page refresh its token and
    // retry, or tell the user honestly while keeping their unsaved work on screen.
    $wantsJson = (strcasecmp((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? ''), 'xmlhttprequest') === 0)
              || in_array(($_POST['action'] ?? ''), ['autosave', 'ping'], true)
              || (($_GET['action'] ?? '') === 'ping');
    $bail = function (string $reason) use ($wantsJson) {
        while (ob_get_level() > 0) ob_end_clean();
        http_response_code(401);
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode(['ok' => false, 'reason' => $reason]);
        exit;
    };

    if (!$user) {
        if ($wantsJson) $bail('auth');
        flash('error', 'Please sign in to continue.');
        redirect('/login.php');
    }

    // Type isolation: internal user cannot access client routes & vice-versa.
    if ($type === 'internal' && $user['user_type'] !== 'internal') {
        logout();
        redirect('/login.php');
    }
    if ($type === 'client' && $user['user_type'] !== 'client') {
        logout();
        redirect('/login.php');
    }

    // Status check
    if ($user['status'] !== 'active') {
        logout();
        flash('error', 'Your account is not active. Contact your administrator.');
        redirect('/login.php');
    }

    // Idle timeout
    $maxIdle = ($type === 'client' ? SESSION_TIMEOUT_CLIENT_MIN : SESSION_TIMEOUT_INTERNAL_MIN) * 60;
    if (!empty($_SESSION['last_activity']) && time() - $_SESSION['last_activity'] > $maxIdle) {
        logout();
        if ($wantsJson) $bail('idle');
        flash('error', 'Your session expired due to inactivity.');
        redirect('/login.php');
    }
    $_SESSION['last_activity'] = time();

    // IP & user-agent binding. IP match is configurable (SESSION_IP_BIND) because
    // CGNAT / mobile / cPanel-behind-LB setups rotate REMOTE_ADDR between requests
    // and strict matching causes spurious logouts (most often on POST→redirect Save).
    $sessIp = (string) ($_SESSION['ip'] ?? '');
    $reqIp  = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
    if (!session_ip_matches($sessIp, $reqIp) || ($_SESSION['ua_hash'] ?? '') !== ua_hash()) {
        logout();
        if ($wantsJson) $bail('security');
        flash('error', 'Session security check failed. Please sign in again.');
        redirect('/login.php');
    }

    // Zero-touch deploy: the first authenticated load after new code is uploaded
    // auto-applies any pending schema/seed changes (roles, lock-down, indexes…). Idempotent.
    app_auto_provision(db());

    // Role-based page gate: each admin script declares the permission(s) that unlock it
    // (see admin_route_permissions()).  Unmapped scripts stay login-only.  Centralising
    // this here means no admin page can accidentally ship without an access check.
    if ($type === 'internal') {
        $needed = admin_route_permissions(basename((string) ($_SERVER['SCRIPT_NAME'] ?? '')));
        if ($needed) {
            $uid = (int) $user['id'];
            $ok  = in_array('__sysadmin__', $needed, true)
                 ? user_has_role($uid, 'sysadmin')
                 : false;
            if (!$ok) foreach ($needed as $pc) {
                if ($pc === '__sysadmin__') continue;
                if ($pc === '__masterdata__') { if (can_manage_master_data()) { $ok = true; break; } continue; }  // any vertical's supervisor/reference-admin
                if (user_has_permission($uid, $pc)) { $ok = true; break; }
            }
            if (!$ok) {
                flash('error', "You don't have access to that area. Ask an administrator if you need it.");
                redirect('/dashboard.php');
            }
        }
    }

    return $user;
}

/** Admin script basename => permission(s) that unlock it (ANY-of). Unmapped = login-only.
 *  '__sysadmin__' means SysAdmin role only. Keep in sync with the sidebar in header.php. */
function admin_route_permissions(string $script): array
{
    $map = [
        'verticals.php'         => ['vertical.view'],
        'clients.php'           => ['client.view'],
        'processes.php'         => ['process.view'],
        'report-builder.php'    => ['__sysadmin__'],   // creating/editing reports is SysAdmin-only
        'workspace.php'         => ['report.view'],
        'workspace-v2.php'      => ['report.view'],
        'reports.php'           => ['report.view'],
        'report-view.php'       => ['report.view'],
        'collection.php'        => ['report.view'],
        'collection-drill.php'  => ['report.view'],
        'template.php'          => ['report.view'],
        'export.php'            => ['report.download'],
        'upload.php'            => ['report.upload'],
        'upload-collection.php' => ['report.upload'],
        'facility-mapping.php'  => ['report.upload'],
        'deductible-mapping.php'=> ['report.upload'],
        'upload-chunk.php'      => ['report.upload'],
        'restore.php'           => ['report.restore'],
        'users.php'             => ['user.internal.view', 'user.client.view', 'user.internal.manage', 'user.client.manage'],
        'access.php'            => ['report.access.view', 'report.access.grant'],
        'audit.php'             => ['audit.view'],
        'login-logs.php'        => ['audit.view'],
        'scheduled.php'         => ['schedule.manage'],
        'settings.php'          => ['settings.view', 'settings.manage'],
        'reference.php'         => ['reference.view'],
        // impersonate.php is intentionally NOT gated here: while impersonating, the current user is the
        // (non-sysadmin) target, so a gate would block Stop/Switch. It self-authorises against the real
        // sysadmin behind the session (see admin/impersonate.php).
        // Permissions controller — feature-permission overrides only (SysAdmin). Coder DATA scope was
        // split out to coding-access.php (2026-06-18 access-control rebuild).
        'access-control.php'    => ['__sysadmin__'],
        // Role default-features editor (role_permissions) — SysAdmin only.
        'roles.php'             => ['__sysadmin__'],
        // ---- CODING module (each page ANY-of the listed perms) ----
        'coding-home.php'       => ['coding.upload','coding.work','coding.audit','coding.validate','coding.supervise','coding.reports','coding.reference','coding.cpt.manage','coding.access.manage'],
        'coding-upload.php'     => ['coding.upload'],
        'coding-bs-upload.php'  => ['coding.bs_upload'],
        'coding-bs-view.php'    => ['coding.work','coding.bs_upload','coding.supervise','coding.audit','coding.validate'],
        'coding-inventory.php'  => ['coding.work','coding.supervise'],
        'coding-chart.php'      => ['coding.work','coding.supervise','coding.audit'],   // auditors/supervisors open charts (read-only unless the in-page edit-gate allows); coders edit their own
        'coding-audit.php'      => ['coding.audit'],
        'coding-rebuttals.php'  => ['coding.work'],
        'coding-hierarchy.php'  => ['__masterdata__'],
        'coding-cpt.php'        => ['coding.cpt.manage'],
        'coding-access.php'     => ['coding.access.manage'],
        'user-alignment.php'    => ['__sysadmin__','coding.access.manage','ar.supervise','billing.supervise'],   // sysadmin (all) + each vertical's mgr/supervisor (fenced to own team + vertical in-page)
        'clone-tree.php'        => ['__sysadmin__'],   // clone a master tree across verticals (SysAdmin bulk write)
        'employee-import.php'   => ['__sysadmin__'],
        'data-cleanup.php'      => ['__sysadmin__'],   // destructive cross-vertical test-record deletion (SysAdmin only)
        'coding-reassign.php'   => ['coding.supervise'],
        'coding-priority.php'   => ['coding.supervise'],
        'coding-rto.php'        => ['coding.supervise','coding.audit'],
        'coding-validate.php'   => ['coding.validate'],
        'coding-reasons.php'    => ['coding.supervise'],
        'coding-modifiers.php'  => ['coding.supervise'],
        'coding-chart-view.php' => ['coding.work','coding.supervise','coding.audit','coding.validate'], // read-only chart detail (per-chart scope fenced in-page)
        'coding-alf.php'        => ['coding.supervise'],
        'coding-reference.php'  => ['coding.reference'],
        'coding-reports.php'    => ['coding.reports'],
        'coding-inventory-report.php' => ['coding.reports','coding.supervise','coding.work','coding.validate','coding.audit'],
        'coding-history.php'    => ['coding.validate','coding.supervise','coding.reports'],
        'coding-deliverables.php' => ['coding.supervise','coding.audit'],
        // Any of the three: a coder opens it to see their own requests, a lead to review, a manager to delete.
        // The destructive action inside is separately gated on coding_can_delete_charts() — route access here
        // grants VISIBILITY of the queue, never the trigger.
        'coding-deletions.php'   => ['coding.deletion.request','coding.deletion.review','coding.deletion.execute'],
        'coding-review-editable.php' => ['coding.supervise'],       // pre-audit review: edit team's completed-not-audited charts
        'coding-facilities.php' => ['__masterdata__'],
        'coding-providers.php'  => ['__masterdata__'],
        // ---- BILLING module ----
        'my-billing.php'        => ['billing.work','billing.supervise','billing.allocate'], // biller works own charts; supervisor/manager also gets ?mode=team inventory
        'billing-rebuttals.php' => ['billing.work'],               // biller: their own QA errors + dispute/rework
        'billing-allocation.php'=> ['billing.allocate'],           // supervisor/manager: allocate, reallocate, raise RFI
        'billing-audit.php'     => ['billing.audit'],              // billing auditor/supervisor: QA sampled charts (DPO)
        'billing-sampling.php'  => ['billing.supervise'],          // supervisor/manager: per-biller audit sample %
        'billing-codes.php'     => ['billing.allocate'],           // supervisor/manager: carriers + Unbilled reasons + comments
        'billing-reports.php'   => ['billing.reports','billing.supervise','billing.allocate'],
        // ---- AR / Collections allocation module ----
        'ar-codes.php'          => ['ar.allocate'],                 // supervisor: customizable claim-status / action lists
        'ar-specialty.php'      => ['ar.supervise','ar.allocate'],  // supervisor/manager: maintain CPT ↔ Specialty map (drives collection Specialty filter)
        'ar-allocation.php'     => ['ar.allocate'],                 // supervisor: run allocation, pull-back, reassign
        'ar-force.php'          => ['ar.allocate'],                 // supervisor: force-allocate specific accounts (inventory download + upload)
        'ar-queue.php'          => ['ar.work'],                     // AR worker only (Billing is a separate dept)
        'ar-work.php'           => ['ar.work'],                     // AR worker: per-visit GetNext work view
        'ar-verify.php'         => ['ar.supervise'],               // supervisor/manager: verify & route bucketed visits (client / coding)
        'ar-rebuttals.php'      => ['ar.work'],                     // AR worker: review audit results + raise rebuttals
        'ar-audit.php'          => ['ar.audit'],                    // AR auditor: 5-parameter audit + resolve rebuttals
        'ar-sampling.php'       => ['ar.supervise'],               // supervisor/manager: per-AR-user audit sample %
        'ar-reports.php'        => ['ar.reports'],                  // role-scoped reports + inventory pivots (granted to all 4 AR roles)
    ];
    $perms = $map[$script] ?? [];
    // Per-PAGE access: every grantable page ALSO opens for its own dedicated `page.<file>` permission, so an admin can
    // grant ONE page to ANY user regardless of role. Added ANY-of (prepended), so capability/role holders keep access.
    // SysAdmin-only tools are intentionally excluded (not in medmecs_grantable_pages) and stay SysAdmin-only.
    if (isset(medmecs_grantable_pages()[$script])) array_unshift($perms, page_perm_code($script));
    return $perms;
}

/** The dedicated per-page access permission code for an admin script — `page.<file-without-.php>`. */
function page_perm_code(string $script): string
{
    return 'page.' . preg_replace('/\.php$/', '', $script);
}

/** Canonical list of GRANTABLE pages: `file => ['label'=>…, 'area'=>…]`. These pages each get a dedicated
 *  `page.<file>` access permission so any single page can be granted to any user irrespective of role. Excludes
 *  SysAdmin-only tools (roles/access-control/clone-tree/employee-import/data-cleanup/errors/report-builder) and pure
 *  endpoints/partials. SINGLE SOURCE OF TRUTH for: the page-code injection in admin_route_permissions(), the
 *  `page_access_perms_v1` seed, and the access-control catalog. Adding a page here needs a seed-migration bump. */
function medmecs_grantable_pages(): array
{
    static $p = null;
    if ($p !== null) return $p;
    $mk = fn(string $label, string $area) => ['label' => $label, 'area' => $area];
    return $p = [
        // ── Coding ──
        'coding-home.php'             => $mk('Coding home', 'coding'),
        'coding-upload.php'           => $mk('Upload inventory', 'coding'),
        'coding-inventory.php'        => $mk('My inventory', 'coding'),
        'coding-chart.php'            => $mk('Code a chart', 'coding'),
        'coding-chart-view.php'       => $mk('Chart detail (read-only)', 'coding'),
        'coding-rebuttals.php'        => $mk('My coding rebuttals', 'coding'),
        'coding-bs-upload.php'        => $mk('Billing sheets — upload', 'coding'),
        'coding-bs-view.php'          => $mk('Billing sheets — view', 'coding'),
        'coding-audit.php'            => $mk('Audit charts', 'coding'),
        'coding-validate.php'         => $mk('Validate RFI / Disregard', 'coding'),
        'coding-history.php'          => $mk('Validation history', 'coding'),
        'coding-rto.php'              => $mk('Return to office (RTO)', 'coding'),
        'coding-reassign.php'         => $mk('Reassign charts', 'coding'),
        'coding-priority.php'         => $mk('Set priority', 'coding'),
        'coding-review-editable.php'  => $mk('Pre-audit review', 'coding'),
        'coding-reasons.php'          => $mk('Reasons & re-tag', 'coding'),
        'coding-modifiers.php'        => $mk('CPT modifiers', 'coding'),
        'coding-alf.php'              => $mk('CPT / ALF billing map', 'coding'),
        'coding-deliverables.php'     => $mk('Deliverables & release to billing', 'coding'),
        'coding-deletions.php'        => $mk('Deletion requests', 'coding'),
        'coding-cpt.php'              => $mk('CPT validation', 'coding'),
        'coding-access.php'           => $mk('Coder access (team)', 'coding'),
        'coding-reference.php'        => $mk('Coding reference', 'coding'),
        'coding-reports.php'          => $mk('Coding reports', 'coding'),
        'coding-inventory-report.php' => $mk('Reconciliation report', 'coding'),
        // ── AR / Collections ──
        'ar-queue.php'      => $mk('My AR queue', 'ar'),
        'ar-work.php'       => $mk('Work an account (per-visit)', 'ar'),
        'ar-rebuttals.php'  => $mk('My AR rebuttals', 'ar'),
        'ar-allocation.php' => $mk('Allocate AR inventory', 'ar'),
        'ar-force.php'      => $mk('Force-allocate accounts', 'ar'),
        'ar-codes.php'      => $mk('Claim status & actions', 'ar'),
        'ar-specialty.php'  => $mk('CPT ↔ Specialty map', 'ar'),
        'ar-verify.php'     => $mk('Verify & route visits', 'ar'),
        'ar-sampling.php'   => $mk('AR audit sampling %', 'ar'),
        'ar-audit.php'      => $mk('AR audit', 'ar'),
        'ar-reports.php'    => $mk('AR reports', 'ar'),
        // ── Billing ──
        'my-billing.php'         => $mk('My billing queue', 'billing'),
        'billing-rebuttals.php'  => $mk('My billing rebuttals', 'billing'),
        'billing-allocation.php' => $mk('Allocate billing', 'billing'),
        'billing-audit.php'      => $mk('Billing audit', 'billing'),
        'billing-sampling.php'   => $mk('Billing audit sampling %', 'billing'),
        'billing-codes.php'      => $mk('Carriers & codes', 'billing'),
        'billing-reports.php'    => $mk('Billing reports', 'billing'),
        // ── Master data (shared) ──
        'coding-facilities.php' => $mk('Facilities', 'master'),
        'coding-providers.php'  => $mk('Providers', 'master'),
        'coding-hierarchy.php'  => $mk('Hierarchy', 'master'),
        'clients.php'           => $mk('Clients', 'master'),
        'processes.php'         => $mk('Processes', 'master'),
        'verticals.php'         => $mk('Verticals', 'master'),
        // ── Reports platform — intentionally NOT page-grantable. Report access is governed by the dedicated
        //    report_access system (admin/access.php) + the report.* capabilities, per-report, NOT by a blanket page
        //    code. A page grant would bypass that per-report data scope (raw claim PHI), so these stay on report.*. ──
        // ── Platform administration ──
        'users.php'          => $mk('Users & roles', 'platform'),
        'user-alignment.php' => $mk('User data-scope alignment', 'platform'),
        'audit.php'          => $mk('Audit log', 'platform'),
        'login-logs.php'     => $mk('Login activity', 'platform'),
        'settings.php'       => $mk('Settings', 'platform'),
        'scheduled.php'      => $mk('Scheduled reports', 'platform'),
        'reference.php'      => $mk('System reference', 'platform'),
    ];
}

/**
 * PAGE-CENTRIC access catalog — the single source of truth for "every feature page and the permission(s) that
 * unlock it", shared by admin/access-control.php (per-user grid) AND admin/roles.php (role defaults) so a new page
 * added here appears on BOTH and can never be silently skipped. Each page row:
 *   ['file','label','icon','gate'=>[codes], 'also'=>[caps], 'note'?, 'sec'?/'shared'?]
 * 'gate' = codes that grant page ACCESS (mirrors admin_route_permissions(); '__sysadmin__' = SysAdmin-only,
 * 'master.data.manage' = the shared master-data gate). 'also' = extra capabilities exercised ON that page.
 * Returns ['verticals','master','reports','platform'].
 */
function medmecs_page_catalog(): array
{
    static $c = null;
    if ($c !== null) return $c;
    $verticals = [
        // ── CODING vertical ──
        ['sec'=>'coding','file'=>'coding-upload.php','label'=>'Upload inventory','icon'=>'upload','gate'=>['coding.upload']],
        ['sec'=>'coding','file'=>'coding-inventory.php','label'=>'My inventory','icon'=>'list-check','gate'=>['coding.work','coding.supervise']],
        ['sec'=>'coding','file'=>'coding-chart.php','label'=>'Code a chart','icon'=>'file-text','gate'=>['coding.work','coding.supervise','coding.audit']],
        ['sec'=>'coding','file'=>'coding-rebuttals.php','label'=>'My rebuttals','icon'=>'message-report','gate'=>['coding.work']],
        ['sec'=>'coding','file'=>'coding-bs-upload.php','label'=>'Billing sheets — upload','icon'=>'file-invoice','gate'=>['coding.bs_upload']],
        ['sec'=>'coding','file'=>'coding-bs-view.php','label'=>'Billing sheets — view','icon'=>'file-search','gate'=>['coding.work','coding.bs_upload','coding.supervise','coding.audit','coding.validate']],
        ['sec'=>'coding','file'=>'coding-audit.php','label'=>'Audit charts','icon'=>'checklist','gate'=>['coding.audit']],
        ['sec'=>'coding','file'=>'coding-validate.php','label'=>'Validate RFI / Disregard','icon'=>'shield-check','gate'=>['coding.validate']],
        ['sec'=>'coding','file'=>'coding-history.php','label'=>'Validation history','icon'=>'history','gate'=>['coding.validate','coding.supervise','coding.reports']],
        ['sec'=>'coding','file'=>'coding-rto.php','label'=>'Return to office (RTO)','icon'=>'arrow-back-up','gate'=>['coding.supervise','coding.audit']],
        ['sec'=>'coding','file'=>'coding-reassign.php','label'=>'Reassign charts','icon'=>'arrows-shuffle','gate'=>['coding.supervise']],
        ['sec'=>'coding','file'=>'coding-review-editable.php','label'=>'Pre-audit review (edit team completed)','icon'=>'edit-circle','gate'=>['coding.supervise']],
        ['sec'=>'coding','file'=>'coding-priority.php','label'=>'Set priority','icon'=>'flag','gate'=>['coding.supervise']],
        ['sec'=>'coding','file'=>'coding-reasons.php','label'=>'Reasons &amp; re-tag','icon'=>'list','gate'=>['coding.supervise']],
        ['sec'=>'coding','file'=>'coding-modifiers.php','label'=>'CPT modifiers','icon'=>'adjustments','gate'=>['coding.supervise']],
        ['sec'=>'coding','file'=>'coding-alf.php','label'=>'CPT / ALF billing map','icon'=>'map-2','gate'=>['coding.supervise']],
        ['sec'=>'coding','file'=>'coding-deliverables.php','label'=>'Deliverables &amp; release to billing','icon'=>'package-export','gate'=>['coding.supervise','coding.audit'],'also'=>['coding.release_billing']],
        ['sec'=>'coding','file'=>'coding-deletions.php','label'=>'Deletion requests','icon'=>'trash-x','gate'=>['coding.deletion.request','coding.deletion.review','coding.deletion.execute']],
        ['sec'=>'coding','file'=>'coding-cpt.php','label'=>'CPT validation','icon'=>'clipboard-check','gate'=>['coding.cpt.manage']],
        ['sec'=>'coding','file'=>'coding-access.php','label'=>'Coder access (team)','icon'=>'users-group','gate'=>['coding.access.manage']],
        ['sec'=>'coding','file'=>'coding-reference.php','label'=>'Coding reference','icon'=>'book','gate'=>['coding.reference']],
        ['sec'=>'coding','file'=>'coding-reports.php','label'=>'Coding reports','icon'=>'chart-histogram','gate'=>['coding.reports']],
        ['sec'=>'coding','file'=>'coding-inventory-report.php','label'=>'Reconciliation report','icon'=>'report-analytics','gate'=>['coding.reports','coding.supervise','coding.work','coding.validate','coding.audit']],
        // ── AR / Collections vertical ──
        ['sec'=>'ar','file'=>'ar-queue.php','label'=>'My AR queue','icon'=>'inbox','gate'=>['ar.work'],'also'=>['ar.reports']],
        ['sec'=>'ar','file'=>'ar-work.php','label'=>'Work an account (per-visit)','icon'=>'user-check','gate'=>['ar.work']],
        ['sec'=>'ar','file'=>'ar-rebuttals.php','label'=>'My AR rebuttals','icon'=>'message-report','gate'=>['ar.work']],
        ['sec'=>'ar','file'=>'ar-allocation.php','label'=>'Allocate inventory','icon'=>'arrows-shuffle','gate'=>['ar.allocate']],
        ['sec'=>'ar','file'=>'ar-force.php','label'=>'Force-allocate accounts','icon'=>'user-share','gate'=>['ar.allocate']],
        ['sec'=>'ar','file'=>'ar-codes.php','label'=>'Claim status &amp; actions','icon'=>'list-check','gate'=>['ar.allocate']],
        ['sec'=>'ar','file'=>'ar-specialty.php','label'=>'CPT ↔ Specialty map','icon'=>'stethoscope','gate'=>['ar.supervise','ar.allocate']],
        ['sec'=>'ar','file'=>'ar-verify.php','label'=>'Verify &amp; route visits','icon'=>'checks','gate'=>['ar.supervise']],
        ['sec'=>'ar','file'=>'ar-sampling.php','label'=>'Audit sampling %','icon'=>'percentage','gate'=>['ar.supervise']],
        ['sec'=>'ar','file'=>'ar-audit.php','label'=>'AR audit','icon'=>'clipboard-check','gate'=>['ar.audit']],
        ['sec'=>'ar','file'=>'ar-reports.php','label'=>'AR reports','icon'=>'chart-histogram','gate'=>['ar.reports'],'also'=>['ar.supervise','ar.audit']],
        // ── BILLING vertical ──
        ['sec'=>'billing','file'=>'my-billing.php','label'=>'My billing queue','icon'=>'inbox','gate'=>['billing.work','billing.supervise','billing.allocate']],
        ['sec'=>'billing','file'=>'billing-rebuttals.php','label'=>'My billing rebuttals','icon'=>'message-report','gate'=>['billing.work']],
        ['sec'=>'billing','file'=>'billing-allocation.php','label'=>'Allocate billing','icon'=>'arrows-split','gate'=>['billing.allocate']],
        ['sec'=>'billing','file'=>'billing-audit.php','label'=>'Billing audit','icon'=>'shield-check','gate'=>['billing.audit'],'also'=>['billing.supervise']],
        ['sec'=>'billing','file'=>'billing-sampling.php','label'=>'Audit sampling %','icon'=>'percentage','gate'=>['billing.supervise']],
        ['sec'=>'billing','file'=>'billing-codes.php','label'=>'Carriers &amp; codes','icon'=>'list-details','gate'=>['billing.allocate']],
        ['sec'=>'billing','file'=>'billing-reports.php','label'=>'Billing reports','icon'=>'chart-bar','gate'=>['billing.reports','billing.supervise','billing.allocate']],
    ];
    $master = [
        ['shared'=>1,'file'=>'coding-facilities.php','label'=>'Facilities','icon'=>'building-hospital','gate'=>['master.data.manage']],
        ['shared'=>1,'file'=>'coding-providers.php','label'=>'Providers','icon'=>'user-plus','gate'=>['master.data.manage']],
        ['shared'=>1,'file'=>'coding-hierarchy.php','label'=>'Hierarchy (facility ↔ provider ↔ CPT)','icon'=>'sitemap','gate'=>['master.data.manage']],
        ['shared'=>1,'file'=>'clients.php','label'=>'Clients','icon'=>'users','gate'=>['client.view'],'also'=>['client.manage','client.branding.manage']],
        ['shared'=>1,'file'=>'processes.php','label'=>'Processes','icon'=>'list-tree','gate'=>['process.view'],'also'=>['process.manage']],
        ['shared'=>1,'file'=>'verticals.php','label'=>'Verticals','icon'=>'building','gate'=>['vertical.view'],'also'=>['vertical.manage']],
    ];
    $reports = [
        ['file'=>'reports.php','label'=>'Reports browser','icon'=>'report-analytics','note'=>'collection dashboards','gate'=>['report.view'],'also'=>['report.view_detail','report.delete','report.replace_all']],
        ['file'=>'report-builder.php','label'=>'Report builder','icon'=>'template','gate'=>['__sysadmin__'],'also'=>['report.definition.view','report.definition.manage']],
        ['file'=>'workspace.php','label'=>'Report workspace / dashboard','icon'=>'layout-dashboard','gate'=>['report.view'],'also'=>['dashboard.view']],
        ['file'=>'upload.php','label'=>'Upload collection data','icon'=>'upload','gate'=>['report.upload']],
        ['file'=>'export.php','label'=>'Export reports','icon'=>'download','gate'=>['report.download']],
        ['file'=>'restore.php','label'=>'Restore snapshots','icon'=>'history','gate'=>['report.restore']],
        ['file'=>'access.php','label'=>'Manage Report Access (grant to others)','icon'=>'key','gate'=>['report.access.view','report.access.grant'],'also'=>['report.access.audit']],
    ];
    $platform = [
        ['title'=>'Users &amp; roles','icon'=>'users','pages'=>[
            ['file'=>'users.php','label'=>'Users &amp; roles','icon'=>'user-cog','gate'=>['user.internal.view','user.client.view','user.internal.manage','user.client.manage'],'also'=>['role.manage','user.scope.assign']],
            ['file'=>'user-alignment.php','label'=>'User data-scope alignment','icon'=>'users-group','note'=>'align users to client»process»facility»provider (also opens for AR / Billing supervisors)','gate'=>['coding.access.manage','ar.supervise','billing.supervise']],
        ]],
        ['title'=>'Logs &amp; audit','icon'=>'history','pages'=>[
            ['file'=>'audit.php','label'=>'Audit log','icon'=>'history','gate'=>['audit.view']],
            ['file'=>'login-logs.php','label'=>'Login activity','icon'=>'shield-lock','gate'=>['audit.view']],
        ]],
        ['title'=>'Settings &amp; operations','icon'=>'settings','pages'=>[
            ['file'=>'settings.php','label'=>'Settings','icon'=>'settings','gate'=>['settings.view','settings.manage'],'also'=>['backup.manage']],
            ['file'=>'scheduled.php','label'=>'Scheduled reports','icon'=>'clock-play','gate'=>['schedule.manage']],
            ['file'=>'reference.php','label'=>'System reference','icon'=>'info-circle','gate'=>['reference.view']],
        ]],
        ['title'=>'SysAdmin tools','icon'=>'shield-lock','pages'=>[
            ['file'=>'roles.php','label'=>'Roles &amp; default features','icon'=>'shield-cog','gate'=>['__sysadmin__']],
            ['file'=>'access-control.php','label'=>'Permissions controller','icon'=>'key','gate'=>['__sysadmin__']],
            ['file'=>'clone-tree.php','label'=>'Clone master tree across verticals','icon'=>'copy','gate'=>['__sysadmin__']],
            ['file'=>'employee-import.php','label'=>'Employee import','icon'=>'user-plus','gate'=>['__sysadmin__']],
            ['file'=>'data-cleanup.php','label'=>'Data cleanup — delete test records','icon'=>'trash','gate'=>['__sysadmin__']],
            ['file'=>'errors.php','label'=>'Error log','icon'=>'bug','gate'=>['__sysadmin__']],
        ]],
    ];
    return $c = ['verticals'=>$verticals, 'master'=>$master, 'reports'=>$reports, 'platform'=>$platform];
}

function ua_hash(): string
{
    return hash('sha256', $_SERVER['HTTP_USER_AGENT'] ?? '');
}

/**
 * Compare the request IP against the session-bound IP per SESSION_IP_BIND policy.
 * Returns true if the session is allowed to continue.  See config.php for the
 * available modes ('strict' | 'prefix24' | 'prefix16' | 'off').
 */
function session_ip_matches(string $sessIp, string $reqIp): bool
{
    $mode = defined('SESSION_IP_BIND') ? (string) SESSION_IP_BIND : 'prefix24';
    if ($mode === 'off')    return true;
    if ($sessIp === '')     return true;                  // first request after login
    if ($sessIp === $reqIp) return true;                  // exact

    // Loopback is the same machine in every form — fixes localhost dual-stack (::1 ↔ 127.0.0.1),
    // a common cause of spurious logouts on POST→Save during local testing.
    $loop = function (string $ip): bool { $ip = strtolower($ip);
        return $ip === '::1' || str_starts_with($ip, '127.') || str_starts_with($ip, '::ffff:127.'); };
    if ($loop($sessIp) && $loop($reqIp)) return true;

    if ($mode === 'strict') return false;

    $isV4 = fn(string $ip) => (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
    if (!$isV4($sessIp) || !$isV4($reqIp)) {              // IPv6 → compare the /64 routing prefix (tolerates privacy-address rotation)
        $a = @inet_pton($sessIp); $b = @inet_pton($reqIp);
        if ($a !== false && $b !== false && strlen($a) === 16 && strlen($b) === 16) return substr($a, 0, 8) === substr($b, 0, 8);
        return false;
    }
    $take = $mode === 'prefix16' ? 2 : 3;                 // prefix24 default
    return array_slice(explode('.', $sessIp), 0, $take) === array_slice(explode('.', $reqIp), 0, $take);
}

/** Set the session for a user that has just authenticated. */
function login_user(array $user): void
{
    start_session();
    session_regenerate_id(true);
    $_SESSION['uid']            = (int) $user['id'];
    $_SESSION['user_type']      = $user['user_type'];
    $_SESSION['client_id']      = $user['client_id'] ?? null;
    $_SESSION['must_change_pw'] = !empty($user['must_change_password']);
    $_SESSION['login_time']     = time();
    $_SESSION['last_activity']  = time();
    $_SESSION['ip']             = $_SERVER['REMOTE_ADDR'] ?? '';
    $_SESSION['ua_hash']        = ua_hash();
}

function logout(): void
{
    start_session();
    $_SESSION = [];
    if (ini_get('session.use_cookies')) {
        $p = session_get_cookie_params();
        setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
    }
    session_destroy();
}

/* ----- IMPERSONATION (sysadmin "view as" another user) ----- */

/** True if the current session is an impersonation (sysadmin acting as someone else). */
function is_impersonating(): bool
{
    start_session();
    return !empty($_SESSION['_impersonator_uid']);
}

function impersonator_id(): ?int
{
    start_session();
    return isset($_SESSION['_impersonator_uid']) ? (int) $_SESSION['_impersonator_uid'] : null;
}

/** Begin impersonating $targetId. Caller MUST verify is sysadmin first. */
function begin_impersonation(int $targetId): void
{
    start_session();
    $current = current_user();
    if (!$current || !user_has_role((int) $current['id'], 'sysadmin')) {
        http_response_code(403); exit('Only sysadmin can impersonate.');
    }
    if ((int) $current['id'] === $targetId) return;
    $stmt = db()->prepare('SELECT id, name, email, user_type, client_id, status, must_change_password
                           FROM users WHERE id = ?');
    $stmt->execute([$targetId]);
    $target = $stmt->fetch();
    if (!$target) { http_response_code(404); exit('Target user not found.'); }

    // Save original sysadmin id, then switch
    $origId = (int) $current['id'];
    session_regenerate_id(true);
    $_SESSION['_impersonator_uid'] = $origId;
    $_SESSION['uid']          = (int) $target['id'];
    $_SESSION['user_type']    = $target['user_type'];
    $_SESSION['client_id']    = $target['client_id'] ?? null;
    $_SESSION['must_change_pw'] = false; // sysadmin doesn't need to rotate someone else's pw
    $_SESSION['login_time']   = time();
    $_SESSION['last_activity']= time();
    $_SESSION['ip']           = $_SERVER['REMOTE_ADDR'] ?? '';
    $_SESSION['ua_hash']      = ua_hash();
}

/** End impersonation, return to original sysadmin session. */
function end_impersonation(): void
{
    start_session();
    if (empty($_SESSION['_impersonator_uid'])) return;
    $origId = (int) $_SESSION['_impersonator_uid'];
    $stmt = db()->prepare('SELECT id, name, email, user_type, client_id, status FROM users WHERE id = ?');
    $stmt->execute([$origId]);
    $orig = $stmt->fetch();
    if (!$orig) { logout(); return; }
    session_regenerate_id(true);
    unset($_SESSION['_impersonator_uid']);
    $_SESSION['uid']         = (int) $orig['id'];
    $_SESSION['user_type']   = $orig['user_type'];
    $_SESSION['client_id']   = $orig['client_id'];
    $_SESSION['must_change_pw'] = false;
    $_SESSION['last_activity']  = time();
}


/* ----- role / permission checks (minimal v1) ----- */

function user_has_role(int $userId, string $roleCode): bool
{
    $stmt = db()->prepare(
        'SELECT 1 FROM user_roles ur
         JOIN roles r ON r.id = ur.role_id
         WHERE ur.user_id = ? AND r.code = ? LIMIT 1'
    );
    $stmt->execute([$userId, $roleCode]);
    return (bool) $stmt->fetchColumn();
}

/**
 * Per-user permission OVERRIDE for one code: 'grant' | 'deny' | null (inherit role).
 * Loaded once per (user) request into a static map. Tolerates the table not existing
 * yet (truly fresh DB, before the coding migration runs) by returning null.
 */
function user_permission_overrides(int $userId): array
{
    static $cache = [];
    if (array_key_exists($userId, $cache)) return $cache[$userId];
    try {
        $st = db()->prepare('SELECT permission_code, effect FROM user_permissions WHERE user_id = ?');
        $st->execute([$userId]);
        $map = [];
        foreach ($st->fetchAll() as $r) $map[$r['permission_code']] = $r['effect'];
        return $cache[$userId] = $map;
    } catch (Throwable $e) {
        return $cache[$userId] = [];   // table not provisioned yet → inherit from roles
    }
}

function user_has_permission(int $userId, string $permCode): bool
{
    if (user_has_role($userId, 'sysadmin')) return true;
    // Per-user override wins over the role default.
    $ov = user_permission_overrides($userId);
    if (isset($ov[$permCode])) return $ov[$permCode] === 'grant';
    $stmt = db()->prepare(
        'SELECT 1
         FROM user_roles ur
         JOIN role_permissions rp ON rp.role_id = ur.role_id
         JOIN permissions p ON p.id = rp.permission_id
         WHERE ur.user_id = ? AND p.code = ? LIMIT 1'
    );
    $stmt->execute([$userId, $permCode]);
    return (bool) $stmt->fetchColumn();
}

/* ----- current-user convenience wrappers (drive the role-based nav + page gates) ----- */

/** All permission codes for the current user, loaded once per request. sysadmin => ['*']. */
function current_user_permissions(): array
{
    static $perms = null;
    if ($perms !== null) return $perms;
    $u = current_user();
    if (!$u) return $perms = [];
    if (user_has_role((int) $u['id'], 'sysadmin')) return $perms = ['*'];
    $stmt = db()->prepare(
        'SELECT DISTINCT p.code
           FROM user_roles ur
           JOIN role_permissions rp ON rp.role_id = ur.role_id
           JOIN permissions p ON p.id = rp.permission_id
          WHERE ur.user_id = ?'
    );
    $stmt->execute([(int) $u['id']]);
    $set = array_fill_keys($stmt->fetchAll(PDO::FETCH_COLUMN) ?: [], true);
    // Apply per-user overrides: grant adds the code, deny removes it.
    foreach (user_permission_overrides((int) $u['id']) as $code => $effect) {
        if ($effect === 'grant') $set[$code] = true; else unset($set[$code]);
    }
    return $perms = array_keys($set);
}

/** Role codes for the current user (for display / badges). */
function current_user_role_codes(): array
{
    static $roles = null;
    if ($roles !== null) return $roles;
    $u = current_user();
    if (!$u) return $roles = [];
    $stmt = db()->prepare('SELECT r.code FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?');
    $stmt->execute([(int) $u['id']]);
    return $roles = ($stmt->fetchAll(PDO::FETCH_COLUMN) ?: []);
}

/** True if the current user holds ANY of the given permissions (sysadmin always true). */
function can(string ...$perm): bool
{
    $perms = current_user_permissions();
    if (in_array('*', $perms, true)) return true;
    foreach ($perm as $p) if (in_array($p, $perms, true)) return true;
    return false;
}

function is_sysadmin(): bool { return in_array('*', current_user_permissions(), true); }

/** Hard-stop: bounce to the dashboard with a message if the user lacks ALL given permissions. */
function require_permission(string ...$perm): void
{
    if (can(...$perm)) return;
    flash('error', "You don't have access to that area. Ask an administrator if you need it.");
    redirect('/dashboard.php');
}

/* ----- per-report access scoping (SysAdmin decides who sees which report) ----- */

/** Report IDs the user may VIEW. null = unrestricted (SysAdmin); [] = none granted. Cached per request. */
function accessible_report_ids(?int $userId = null): ?array
{
    $userId = $userId ?? (int) (current_user()['id'] ?? 0);
    if (!$userId) return [];
    static $cache = [];
    if (array_key_exists($userId, $cache)) return $cache[$userId];
    if (user_has_role($userId, 'sysadmin')) return $cache[$userId] = null;     // all reports
    $st = db()->prepare('SELECT report_id FROM report_access WHERE user_id = ? AND can_view = 1');
    $st->execute([$userId]);
    return $cache[$userId] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN) ?: []);
}

/** True if the user may view a specific report (SysAdmin always true). */
function user_can_view_report(int $userId, int $reportId): bool
{
    $ids = accessible_report_ids($userId);
    return $ids === null ? true : in_array($reportId, $ids, true);
}

/** SQL fragment " AND <col> IN (...)" scoping to the current user's reports.
 *  Returns '' for SysAdmin (unrestricted). IDs are DB integers, so direct interpolation is safe. */
function report_scope_sql(string $col): string
{
    $ids = accessible_report_ids();
    if ($ids === null) return '';
    if (!$ids)         return " AND $col IN (0)";          // none granted → match nothing
    return " AND $col IN (" . implode(',', array_map('intval', $ids)) . ")";
}

/** Hard-stop a report-scoped page when the current user isn't granted that report. */
function require_report_access(int $reportId): void
{
    $u = current_user();
    if ($u && user_can_view_report((int) $u['id'], $reportId)) return;
    flash('error', "You don't have access to that report. Ask an administrator to grant it.");
    redirect('/admin/workspace.php');
}
