<?php
/**
 * Billing workflow module — engine + migrations + helpers. Mirrors the Coding & AR modules.
 *
 * Inventory is NOT uploaded: it is the coding charts RELEASED to billing
 * (coding_inventory WHERE released_to_billing_at IS NOT NULL). A supervisor/manager ALLOCATES those
 * charts to billers; a biller opens them one-by-one and tags each: Carrier + Billing status
 * (Billed / Billed-On-Hold-Deductible / Unbilled) + (for Unbilled) a secondary reason + a comment.
 * Hold-Deductible & Unbilled charts are reallocated by the supervisor; the supervisor can also raise
 * an account to Coding as RFI. Work state persists in billing_allocation (the released coding chart is
 * the unit, keyed by inventory_id); billing_allocation_event keeps the history.
 *
 * Migrations registered via billing_register_migrations(), run by app_auto_provision().
 */

/** The biller's disposition for a chart (fixed set). */
const BILLING_WORK_STATUSES = [
    'billed'          => 'Billed',
    'hold_deductible' => 'Billed - On Hold - Deductible',
    'unbilled'        => 'Unbilled',
];
/** Statuses the supervisor/manager reallocates. */
const BILLING_REALLOCATABLE = ['hold_deductible', 'unbilled'];

/** Error categories a Billing Auditor picks when a reviewed chart has defects (DPO quality model).
 *  LEGACY: kept only so old audited rows still render their stored audit_error_category. New audits score the
 *  5 BILLING_AUDIT_PARAMS below instead (the failed-param labels become the category summary). */
const BILLING_AUDIT_ERROR_CATEGORIES = [
    'Wrong carrier', 'Wrong billing status', 'Missing / wrong secondary reason',
    'Wrong / missing modifier', 'Wrong CPT/ALF', 'Wrong units', 'Wrong / missing ICD',
    'Eligibility / COB', 'Claim not filed', 'Other',
];
/** The 5 BILLING audit parameters (DPO: opportunities = 5, defects = failed params). Each scored pass/fail with a
 *  note; the biller may dispute individual FAILED params. Mirrors AR_AUDIT_PARAMS. Keyed slug → label. */
const BILLING_AUDIT_PARAMS = [
    'patient'           => 'Correct Patient / patient details',
    'eligibility'       => 'Verified eligibility & correct insurance selection',
    'facility_provider' => 'Correct Facility & Provider selection',
    'cpt_icd'           => 'Correct CPT / Units / Modifier / ICD selection',
    'dos'               => 'Correct DOS selection',
];
/** Days a biller has to review/dispute an error-marked audit (rebuttal TAT). Mirrors AR_REBUTTAL_TAT_DAYS. */
const BILLING_REBUTTAL_TAT_DAYS = 5;
/** Default audit sample rate (% of worked charts flagged for QA) when no setting is stored. */
const BILLING_AUDIT_SAMPLE_DEFAULT = 10;

/* ============================================================================ MIGRATIONS */
function billing_register_migrations(): array
{
    return [
        /* ---- customizable dropdowns: Carrier (seeded once from collection_raw) + Unbilled secondary
         *      reasons + comments. Supervisor/manager manage them; the carrier picker is searchable. ---- */
        'billing_dropdowns_v1' => function (PDO $pdo) {
            $pdo->exec("CREATE TABLE IF NOT EXISTS `billing_dropdowns` (
                `id`         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                `kind`       ENUM('carrier','secondary_reason','comment') NOT NULL,
                `label`      VARCHAR(200) NOT NULL,
                `sort_order` INT NOT NULL DEFAULT 0,
                `status`     ENUM('active','inactive') NOT NULL DEFAULT 'active',
                `created_by` BIGINT UNSIGNED NULL,
                `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`), UNIQUE KEY `uq_kind_label` (`kind`,`label`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
            // Seed the CARRIER list ONCE from collection_raw (carrier1/2/3). Editable thereafter.
            if ((int) $pdo->query("SELECT COUNT(*) FROM billing_dropdowns WHERE kind='carrier'")->fetchColumn() === 0) {
                try {
                    $carriers = $pdo->query("SELECT DISTINCT name FROM (
                            SELECT TRIM(carrier1_name) name FROM collection_raw WHERE COALESCE(carrier1_name,'')<>''
                            UNION SELECT TRIM(carrier2_name) FROM collection_raw WHERE COALESCE(carrier2_name,'')<>''
                            UNION SELECT TRIM(carrier3_name) FROM collection_raw WHERE COALESCE(carrier3_name,'')<>''
                        ) t WHERE name<>'' ORDER BY name")->fetchAll(PDO::FETCH_COLUMN);
                    $ins = $pdo->prepare("INSERT IGNORE INTO billing_dropdowns (kind,label,sort_order) VALUES ('carrier',?,0)");
                    foreach ($carriers as $c) $ins->execute([mb_substr($c, 0, 200)]);
                } catch (Throwable $e) { /* collection_raw may not exist yet */ }
            }
        },

        /* ---- persistent allocation + biller work state, one row per released coding chart ---- */
        'billing_allocation_v1' => function (PDO $pdo) {
            $pdo->exec("CREATE TABLE IF NOT EXISTS `billing_allocation` (
                `id`               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                `inventory_id`     BIGINT UNSIGNED NOT NULL,                       -- the released coding_inventory chart
                `allocated_biller` BIGINT UNSIGNED NULL,
                `allocated_by`     BIGINT UNSIGNED NULL,
                `allocated_at`     TIMESTAMP NULL,
                `inventory_status` VARCHAR(40) NULL,                              -- NULL='' = unallocated (allocable); 'allocated','worked'
                `carrier`          VARCHAR(200) NULL,                             -- selected insurance carrier (label snapshot)
                `billing_status`   ENUM('billed','hold_deductible','unbilled') NULL,
                `secondary_reason` VARCHAR(200) NULL,                             -- required when billing_status='unbilled'
                `comment`          VARCHAR(255) NULL,
                `last_worked_date` DATE NULL,
                `worked_at`        TIMESTAMP NULL,
                `rfi_raised_at`    TIMESTAMP NULL,                                -- supervisor raised this account to Coding as RFI
                `rfi_raised_by`    BIGINT UNSIGNED NULL,
                `created_at`       TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                `updated_at`       TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                UNIQUE KEY `uq_inventory` (`inventory_id`),
                KEY `idx_bill_biller` (`allocated_biller`,`inventory_status`),
                KEY `idx_bill_status` (`billing_status`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");

            $pdo->exec("CREATE TABLE IF NOT EXISTS `billing_allocation_event` (
                `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                `allocation_id` BIGINT UNSIGNED NULL,
                `inventory_id`  BIGINT UNSIGNED NULL,
                `event`         VARCHAR(40) NOT NULL,                             -- allocate / reassign / pullback / work / reallocate / rfi_to_coding
                `user_id`       BIGINT UNSIGNED NULL,
                `from_user`     BIGINT UNSIGNED NULL,
                `to_user`       BIGINT UNSIGNED NULL,
                `billing_status` VARCHAR(40) NULL,
                `carrier`       VARCHAR(200) NULL,
                `secondary_reason` VARCHAR(200) NULL,
                `comment`       VARCHAR(255) NULL,
                `created_at`    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                KEY `idx_bve_alloc` (`allocation_id`),
                KEY `idx_bve_inv` (`inventory_id`),
                KEY `idx_bve_event` (`event`,`created_at`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
        },

        /* ---- permission: allocate billing inventory + manage dropdowns + raise RFI to coding ---- */
        'billing_allocate_perm_v1' => function (PDO $pdo) {
            $pdo->prepare("INSERT INTO permissions (code,label,group_name,description,created_at,updated_at)
                           VALUES (?,?,?,?,NOW(),NOW())
                           ON DUPLICATE KEY UPDATE label=VALUES(label), group_name=VALUES(group_name)")
                ->execute(['billing.allocate', 'Allocate billing inventory', 'billing', 'Allocate billing charts to billers, manage carriers/reasons, raise RFI to coding']);
            $pid = (int) $pdo->query("SELECT id FROM permissions WHERE code='billing.allocate'")->fetchColumn();
            if ($pid) {
                $roleNames = ['Billing Manager', 'Billing Supervisor'];
                $in = implode(',', array_fill(0, count($roleNames), '?'));
                $rids = $pdo->prepare("SELECT id FROM roles WHERE name IN ($in)");
                $rids->execute($roleNames);
                $grant = $pdo->prepare("INSERT IGNORE INTO role_permissions (role_id,permission_id) VALUES (?,?)");
                foreach ($rids->fetchAll(PDO::FETCH_COLUMN) as $rid) $grant->execute([(int) $rid, $pid]);
            }
        },

        /* ---- Billing AUDIT (QA): DPO scoring + sampling + rebuttal, on the billing_allocation row.
         *      Mirrors the coding quality model. billing.audit already exists (granted to Billing Auditor/
         *      Manager/Supervisor); this only adds the per-chart audit columns. ---- */
        'billing_audit_v1' => function (PDO $pdo) {
            $cols = [
                'in_sample'            => "TINYINT(1) NOT NULL DEFAULT 0",        // flagged into the audit sample
                'audit_result'         => "ENUM('pass','error') NULL",           // NULL = not audited yet
                'audit_opportunities'  => "INT NULL",
                'audit_defects'        => "INT NULL",
                'audit_error_category' => "VARCHAR(120) NULL",
                'audit_comment'        => "TEXT NULL",
                'auditor_id'           => "BIGINT UNSIGNED NULL",
                'audited_at'           => "TIMESTAMP NULL",
                'rebuttal_status'      => "ENUM('none','raised','accepted','rejected') NOT NULL DEFAULT 'none'",
                'rebuttal_comment'     => "TEXT NULL",
                'rebuttal_at'          => "TIMESTAMP NULL",
                'rebuttal_resolved_at' => "TIMESTAMP NULL",
            ];
            foreach ($cols as $c => $def) {
                if (!app_col_exists($pdo, 'billing_allocation', $c)) $pdo->exec("ALTER TABLE `billing_allocation` ADD COLUMN `$c` $def");
            }
            if (!app_index_exists($pdo, 'billing_allocation', 'idx_bill_audit'))
                $pdo->exec("ALTER TABLE `billing_allocation` ADD KEY `idx_bill_audit` (`in_sample`,`audit_result`)");
        },

        /* ---- Per-BILLER audit sample %. Supervisor/manager sets, per biller, the % of that biller's worked
         *      charts that enter the audit sample. A row here OVERRIDES the global billing.audit_sample_pct;
         *      billers without a row keep using the global rate. Mirrors ar_user_sample. ---- */
        'billing_user_sample_v1' => function (PDO $pdo) {
            $pdo->exec("CREATE TABLE IF NOT EXISTS `billing_user_sample` (
                `user_id`     BIGINT UNSIGNED NOT NULL PRIMARY KEY,
                `sample_pct`  TINYINT UNSIGNED NOT NULL DEFAULT " . BILLING_AUDIT_SAMPLE_DEFAULT . ",
                `updated_by`  BIGINT UNSIGNED NULL,
                `updated_at`  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
        },

        /* ---- per-CPT-line "finalized & billed" selection. A chart's effective (audit-corrected) CPT lines
         *      are shown to the biller, who ticks which line(s) were actually billed on the claim — a chart
         *      may carry several CPTs, even within one DOS. Billing NEVER mutates coding's CPT rows, so the
         *      selection lives here. cpt_code/dos are SNAPSHOT at save so reports stay stable. line_key =
         *      'c'+coding_chart_cpts.id for a real coded line, 'x'+index for an audit-added (synthetic) one. ---- */
        'billing_chart_cpt_v1' => function (PDO $pdo) {
            $pdo->exec("CREATE TABLE IF NOT EXISTS `billing_chart_cpt` (
                `id`           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                `inventory_id` BIGINT UNSIGNED NOT NULL,
                `line_key`     VARCHAR(40) NOT NULL,                 -- 'c'+chart_cpt_id (real) | 'x'+index (synthetic)
                `chart_cpt_id` BIGINT UNSIGNED NULL,                 -- the coding_chart_cpts.id when known
                `cpt_code`     VARCHAR(20) NULL,                     -- snapshot of the effective billable CPT
                `billed_code`      VARCHAR(20) NULL,                 -- the code finalized & billed on the claim (the CPT value OR its ALF)
                `billed_code_type` VARCHAR(8)  NULL,                 -- 'cpt' | 'alf' — which of the CPT/ALF pair the biller billed
                `dos`          DATE NULL,                            -- snapshot
                `billed`       TINYINT(1) NOT NULL DEFAULT 1,
                `billed_by`    BIGINT UNSIGNED NULL,
                `billed_at`    TIMESTAMP NULL,
                `created_at`   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                `updated_at`   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                UNIQUE KEY `uq_inv_line` (`inventory_id`,`line_key`),
                KEY `idx_bcc_inv` (`inventory_id`),
                KEY `idx_bcc_cpt` (`cpt_code`),
                KEY `idx_bcc_billed_at` (`billed_at`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
        },

        /* ---- per-line CPT-vs-ALF choice: which of the "CPT/ALF" pair was finalized & billed on the claim
         *      (existing installs already have billing_chart_cpt — add the two columns if missing). ---- */
        'billing_chart_cpt_code_v1' => function (PDO $pdo) {
            foreach (['billed_code' => "VARCHAR(20) NULL", 'billed_code_type' => "VARCHAR(8) NULL"] as $c => $def) {
                if (!app_col_exists($pdo, 'billing_chart_cpt', $c)) $pdo->exec("ALTER TABLE `billing_chart_cpt` ADD COLUMN `$c` $def");
            }
        },

        /* ---- rework tracking: who FIRST worked a chart vs who LAST (re)worked it, and how many times.
         *      A worked chart can be re-opened ("Rework") and re-saved; we keep both names + the count so
         *      the queue and reports can show original vs rework biller. ---- */
        'billing_rework_track_v1' => function (PDO $pdo) {
            $cols = [
                'first_worked_by' => "BIGINT UNSIGNED NULL",
                'first_worked_at' => "TIMESTAMP NULL",
                'worked_by'       => "BIGINT UNSIGNED NULL",          // last (re)worker
                'work_count'      => "INT NOT NULL DEFAULT 0",
            ];
            foreach ($cols as $c => $def) {
                if (!app_col_exists($pdo, 'billing_allocation', $c)) $pdo->exec("ALTER TABLE `billing_allocation` ADD COLUMN `$c` $def");
            }
            // Backfill from the event log so existing worked charts show their original/last biller immediately.
            try {
                $pdo->exec("UPDATE billing_allocation a
                    JOIN (SELECT inventory_id,
                                 SUBSTRING_INDEX(MIN(CONCAT(created_at,'#',user_id)),'#',-1) first_by, MIN(created_at) first_at,
                                 SUBSTRING_INDEX(MAX(CONCAT(created_at,'#',user_id)),'#',-1) last_by,
                                 COUNT(*) n
                          FROM billing_allocation_event WHERE event='work' AND user_id IS NOT NULL GROUP BY inventory_id) e
                      ON e.inventory_id=a.inventory_id
                    SET a.first_worked_by=COALESCE(a.first_worked_by, e.first_by),
                        a.first_worked_at=COALESCE(a.first_worked_at, e.first_at),
                        a.worked_by      =COALESCE(a.worked_by, e.last_by),
                        a.work_count     =IF(a.work_count=0, e.n, a.work_count)");
            } catch (Throwable $e) { /* best-effort backfill */ }
        },

        /* ---- PER-PARAMETER audit (mirrors ar_audit_param). billing_allocation IS the audit header (one row per
         *      chart); this child holds one row per (allocation, param): pass/fail + note + the biller's per-param
         *      dispute markers. The DPO rollup (audit_result/opportunities=5/defects) is still written back onto
         *      billing_allocation so every existing consumer keeps working. ---- */
        'billing_audit_param_v1' => function (PDO $pdo) {
            $pdo->exec("CREATE TABLE IF NOT EXISTS `billing_audit_param` (
                `id`              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                `allocation_id`   BIGINT UNSIGNED NOT NULL,           -- billing_allocation.id (the audit header)
                `param_key`       VARCHAR(40) NOT NULL,               -- a BILLING_AUDIT_PARAMS key
                `result`          ENUM('pass','fail') NOT NULL DEFAULT 'pass',
                `note`            VARCHAR(255) NULL,                  -- auditor's note (required on a Fail)
                `disputed`        TINYINT(1) NOT NULL DEFAULT 0,      -- biller disputed this failed param
                `dispute_reason`  VARCHAR(255) NULL,
                `rebuttal_result` ENUM('pending','accepted','rejected') NULL,
                `created_at`      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                `updated_at`      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (`id`),
                UNIQUE KEY `uq_bap` (`allocation_id`,`param_key`),
                KEY `idx_bap_alloc` (`allocation_id`),
                KEY `idx_bap_fail` (`result`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
        },

        /* ---- add the 'agreed' rebuttal state (biller accepts the error) + a rebuttal_due TAT date to the audit
         *      header. The enum change is a MODIFY (try/catch) — NOT an app_col_exists ADD. ---- */
        'billing_audit_param_v2' => function (PDO $pdo) {
            try { $pdo->exec("ALTER TABLE `billing_allocation` MODIFY COLUMN `rebuttal_status` ENUM('none','agreed','raised','accepted','rejected') NOT NULL DEFAULT 'none'"); } catch (Throwable $e) {}
            if (!app_col_exists($pdo, 'billing_allocation', 'rebuttal_due')) $pdo->exec("ALTER TABLE `billing_allocation` ADD COLUMN `rebuttal_due` DATE NULL");
        },

        /* ---- the 6th audit item: "Flag only" (advisory). A boolean on the header — NOT a scored parameter, so it
         *      never enters opportunities/defects (quality is only the 5 BILLING_AUDIT_PARAMS). A flagged chart still
         *      routes to the biller (audit_result='error'), but their DPO is unaffected. ---- */
        'billing_audit_flag_v1' => function (PDO $pdo) {
            if (!app_col_exists($pdo, 'billing_allocation', 'audit_flagged')) $pdo->exec("ALTER TABLE `billing_allocation` ADD COLUMN `audit_flagged` TINYINT(1) NOT NULL DEFAULT 0");
        },
    ];
}

/* ============================================================================ HELPERS */

/** Active dropdown labels for a kind ('carrier'|'secondary_reason'|'comment'). */
function billing_dropdowns(PDO $pdo, string $kind): array
{
    try {
        $st = $pdo->prepare("SELECT label FROM billing_dropdowns WHERE kind=? AND status='active' ORDER BY label");
        $st->execute([$kind]);
        return $st->fetchAll(PDO::FETCH_COLUMN);
    } catch (Throwable $e) { return []; }
}

/** Users who can be allocated billing inventory = anyone with billing.work. */
function billing_allocatable_billers(PDO $pdo, ?array $restrictIds = null): array
{
    // $restrictIds: null = every allocatable biller; an array = only those ids (used for team-scoping the picker).
    $sql = "SELECT DISTINCT u.id, u.name, u.email
        FROM users u
        JOIN user_roles ur ON ur.user_id=u.id
        JOIN role_permissions rp ON rp.role_id=ur.role_id
        JOIN permissions p ON p.id=rp.permission_id
        WHERE u.status='active' AND u.user_type='internal' AND p.code='billing.work'";
    if ($restrictIds !== null) {
        if (!$restrictIds) return [];
        $sql .= " AND u.id IN (" . implode(',', array_map('intval', $restrictIds)) . ")";
    }
    return $pdo->query($sql . " ORDER BY u.name")->fetchAll();
}

/** Id set of allocatable billers (server-side validation of POSTed targets). */
function billing_allocatable_biller_ids(PDO $pdo): array
{
    static $ids = null;
    if ($ids === null) $ids = array_map('intval', array_column(billing_allocatable_billers($pdo), 'id'));
    return $ids;
}

/** True if the current user can see ANY part of the billing module. */
function is_billing_module_user(): bool
{
    return can('billing.work', 'billing.allocate', 'billing.audit', 'billing.supervise', 'billing.reports');
}

/**
 * Read-only TEAM billing inventory for a supervisor/manager (admin/my-billing.php?mode=team).
 * Scope = coding_team_scope (reports_to tree): a manager sees their whole sub-tree, a supervisor sees only
 * their reports, SysAdmin (null) sees everyone. Returns:
 *   ['rows'=>[...], 'kpi'=>[biller_id=>[...counts]], 'billers'=>[[id,name]], 'clients'=>[...], 'facilities'=>[...], 'carriers'=>[...]]
 * $filters (all optional): biller_id, inventory_status ('allocated'|'worked'), billing_status, client_id, facility_id, carrier.
 */
function billing_team_inventory(PDO $pdo, int $uid, array $filters = []): array
{
    $empty = ['rows'=>[], 'kpi'=>[], 'billers'=>[], 'clients'=>[], 'facilities'=>[], 'carriers'=>[]];
    $teamIds = coding_team_scope($pdo, $uid);                       // null = sysadmin (unrestricted)
    // The team's allocatable billers (billing.work holders within scope). A supervisor/manager oversees their
    // reports, not themselves — but keep self visible here so a working supervisor still sees their own charts.
    $allIds   = billing_allocatable_biller_ids($pdo);
    $scopeIds = $teamIds === null ? null : array_values(array_intersect($allIds, $teamIds));
    if ($scopeIds !== null && !$scopeIds) return $empty;            // a supervisor with no billers under them

    $rw = billing_released_where('ci');
    $where = ['a.allocated_biller IS NOT NULL', $rw]; $params = [];
    if ($scopeIds !== null) $where[] = 'a.allocated_biller IN (' . implode(',', array_map('intval', $scopeIds)) . ')';

    // Optional narrowing filters (a picked biller is still fenced to the team via the scope clause above).
    if (!empty($filters['biller_id']))  { $bid = (int) $filters['biller_id'];
        if ($scopeIds === null || in_array($bid, $scopeIds, true)) { $where[] = 'a.allocated_biller=?'; $params[] = $bid; } }
    if (($filters['inventory_status'] ?? '') !== '' && in_array($filters['inventory_status'], ['allocated','worked'], true)) {
        $where[] = 'a.inventory_status=?'; $params[] = (string) $filters['inventory_status']; }
    if (($filters['billing_status'] ?? '') !== '' && in_array($filters['billing_status'], ['billed','hold_deductible','unbilled'], true)) {
        $where[] = 'a.billing_status=?'; $params[] = (string) $filters['billing_status']; }
    if (!empty($filters['client_id']))   { $where[] = 'ci.client_id=?';   $params[] = (int) $filters['client_id']; }
    if (!empty($filters['facility_id'])) { $where[] = 'ci.facility_id=?'; $params[] = (int) $filters['facility_id']; }
    if (($filters['carrier'] ?? '') !== '') { $where[] = 'a.carrier=?';   $params[] = (string) $filters['carrier']; }
    $wsql = implode(' AND ', $where);

    $rows = $pdo->prepare("SELECT a.id, a.inventory_id, a.allocated_biller, a.inventory_status, a.billing_status,
            a.carrier, a.secondary_reason, a.allocated_at, a.last_worked_date,
            ci.patient_name, ci.patient_mrn, ci.dos, c.name cn, p.name pn, f.name fn, u.name biller
        FROM billing_allocation a
        JOIN coding_inventory ci ON ci.id=a.inventory_id
        LEFT JOIN clients c ON c.id=ci.client_id
        LEFT JOIN processes p ON p.id=ci.process_id
        LEFT JOIN coding_facilities f ON f.id=ci.facility_id
        LEFT JOIN users u ON u.id=a.allocated_biller
        WHERE $wsql
        ORDER BY u.name, a.allocated_at DESC, a.id DESC
        LIMIT 5000");
    $rows->execute($params);
    $rows = $rows->fetchAll();

    // Per-biller KPI over the SCOPE (not the narrowing filters) so the summary is stable while you filter the table.
    $kpi = [];
    $kWhere = ['a.allocated_biller IS NOT NULL', $rw];
    if ($scopeIds !== null) $kWhere[] = 'a.allocated_biller IN (' . implode(',', array_map('intval', $scopeIds)) . ')';
    $kq = $pdo->query("SELECT a.allocated_biller bid, u.name biller,
            COUNT(*) total,
            SUM(a.inventory_status='allocated') to_work,
            SUM(a.inventory_status='worked') worked,
            SUM(a.billing_status='billed') billed,
            SUM(a.billing_status='hold_deductible') hold_deductible,
            SUM(a.billing_status='unbilled') unbilled
        FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
        LEFT JOIN users u ON u.id=a.allocated_biller
        WHERE " . implode(' AND ', $kWhere) . " GROUP BY a.allocated_biller, u.name ORDER BY u.name");
    foreach ($kq as $r) $kpi[(int) $r['bid']] = $r;

    // Filter-dropdown option lists, scoped to the team's inventory.
    $optSql = function (string $select) use ($pdo, $rw, $scopeIds) {
        $w = ['a.allocated_biller IS NOT NULL', $rw];
        if ($scopeIds !== null) $w[] = 'a.allocated_biller IN (' . implode(',', array_map('intval', $scopeIds)) . ')';
        return $pdo->query("SELECT DISTINCT $select FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
            LEFT JOIN clients c ON c.id=ci.client_id LEFT JOIN coding_facilities f ON f.id=ci.facility_id
            WHERE " . implode(' AND ', $w));
    };
    $clients = $optSql('c.id, c.name')->fetchAll(PDO::FETCH_KEY_PAIR);
    $facils  = $optSql('f.id, f.name')->fetchAll(PDO::FETCH_KEY_PAIR);
    $carrs   = array_values(array_filter($optSql('a.carrier')->fetchAll(PDO::FETCH_COLUMN), fn($v) => $v !== null && $v !== ''));
    sort($carrs); asort($clients); asort($facils);
    $billers = billing_allocatable_billers($pdo, $scopeIds);

    return ['rows'=>$rows, 'kpi'=>$kpi, 'billers'=>$billers, 'clients'=>$clients, 'facilities'=>$facils, 'carriers'=>$carrs];
}

/* ============================================================================ ENGINE
 * The billing inventory = coding charts RELEASED to billing (and not voided). One billing_allocation
 * row per chart, keyed by inventory_id. A chart is "allocable" until a biller owns it; the supervisor
 * then reallocates Hold-Deductible / Unbilled charts or raises an RFI back to coding.
 * --------------------------------------------------------------------------------------------- */

/** SQL fragment: a coding_inventory chart the coding team released to the INTERNAL billing team (not voided,
 *  not routed to an external vendor). External-vendor charts never enter the internal biller queue. */
function billing_released_where(string $alias = 'ci'): string
{
    return "$alias.released_to_billing_at IS NOT NULL AND COALESCE($alias.billing_status,'pending') <> 'void'"
         . " AND COALESCE($alias.release_destination,'internal') = 'internal'";
}

/** Log one billing allocation/work event. */
function billing_log_event(PDO $pdo, ?int $allocId, ?int $invId, string $event, int $by, array $o = []): void
{
    $pdo->prepare("INSERT INTO billing_allocation_event
        (allocation_id,inventory_id,event,user_id,from_user,to_user,billing_status,carrier,secondary_reason,comment)
        VALUES (?,?,?,?,?,?,?,?,?,?)")
        ->execute([$allocId, $invId, $event, $by,
            $o['from_user'] ?? null, $o['to_user'] ?? null, $o['billing_status'] ?? null,
            $o['carrier'] ?? null, $o['secondary_reason'] ?? null, $o['comment'] ?? null]);

    // In-app notifications for billing hand-offs. Bulk allocate/reassign/reallocate dedupe per-request so a
    // biller gets ONE ping per run (not one per chart); audit_sendback / rebuttal_resolve are single-chart.
    static $sent = [];
    try {
        if (in_array($event, ['allocate','reassign','reallocate'], true)) {
            $rcpt = (int) ($o['to_user'] ?? 0); $key = $event . ':' . $rcpt;
            if ($rcpt > 0 && $rcpt !== $by && !isset($sent[$key])) {
                $sent[$key] = 1;
                $msg = $event === 'allocate'  ? 'New charts were allocated to your billing queue.'
                     : ($event === 'reassign' ? 'Charts were reassigned to your billing queue by your supervisor.'
                     : 'Hold / Unbilled charts were reallocated to you for rework.');
                notify($rcpt, 'billing', 'New billing work in your queue', $msg, '/admin/my-billing.php');
            }
        } elseif (($event === 'audit_sendback' || $event === 'rebuttal_resolve') && $allocId) {
            $a = $pdo->prepare("SELECT ba.allocated_biller, c.patient_name FROM billing_allocation ba
                                LEFT JOIN coding_inventory c ON c.id=ba.inventory_id WHERE ba.id=?");
            $a->execute([$allocId]); $a = $a->fetch();
            if ($a && (int) $a['allocated_biller'] > 0 && (int) $a['allocated_biller'] !== $by) {
                $rcpt = (int) $a['allocated_biller']; $pt = $a['patient_name'] ?: 'a chart';
                if ($event === 'audit_sendback')
                    notify($rcpt, 'billing', 'Chart sent back by QA — please rework', $pt . ' — the billing auditor sent this chart back for rework.', '/admin/my-billing.php?status=sentback');
                else
                    notify($rcpt, 'billing', 'Your billing rebuttal was resolved', $pt . ' — your rebuttal was resolved (' . ($o['comment'] ?? 'see audit') . ').', '/admin/my-billing.php?status=worked');
            }
        }
    } catch (Throwable $e) { /* notifications are best-effort */ }
}

/** billing_allocation row id for an inventory chart (0 if none yet). */
function billing_alloc_id(PDO $pdo, int $invId): int
{
    $q = $pdo->prepare("SELECT id FROM billing_allocation WHERE inventory_id=?");
    $q->execute([$invId]);
    return (int) $q->fetchColumn();
}

/** Released charts NOT yet owned by a biller (fresh-allocation candidates), as WHOLE PATIENTS.
 *  Caps the number of PATIENTS (not rows): step 1 picks up to $maxPatients patients by oldest release,
 *  step 2 returns ALL their unallocated charts — so a patient is never half-included by a row limit
 *  (which would otherwise split them). $f optional scope: client_id / process_id / facility_id. */
function billing_candidate_charts(PDO $pdo, array $f = [], int $maxPatients = 5000): array
{
    $w = [billing_released_where('ci')]; $p = [];
    if (!empty($f['client_id']))   { $w[] = "ci.client_id=?";   $p[] = (int) $f['client_id']; }
    if (!empty($f['process_id']))  { $w[] = "ci.process_id=?";  $p[] = (int) $f['process_id']; }
    if (!empty($f['facility_id'])) { $w[] = "ci.facility_id=?"; $p[] = (int) $f['facility_id']; }
    $pk = billing_patient_key_sql('ci');
    $where = implode(' AND ', $w);
    $unowned = "ci.id NOT IN (SELECT inventory_id FROM billing_allocation WHERE allocated_biller IS NOT NULL)";
    $st = $pdo->prepare("SELECT $pk AS pkey FROM coding_inventory ci
            WHERE $where AND $unowned
            GROUP BY pkey ORDER BY MIN(ci.released_to_billing_at) ASC, pkey ASC
            LIMIT " . (int) $maxPatients);
    $st->execute($p);
    $pkeys = $st->fetchAll(PDO::FETCH_COLUMN);
    if (!$pkeys) return [];
    $pin = implode(',', array_fill(0, count($pkeys), '?'));
    $st2 = $pdo->prepare("SELECT ci.id inventory_id, $pk AS pkey FROM coding_inventory ci
            WHERE $where AND $unowned AND $pk IN ($pin)
            ORDER BY ci.released_to_billing_at ASC, ci.id ASC");
    $st2->execute(array_merge($p, $pkeys));
    return $st2->fetchAll();
}

/** SQL expression that identifies a unique PATIENT within a client — used to keep ALL of a patient's
 *  charts with ONE biller. Primary key is the MRN; when MRN is blank we fall back to name+DOB. Client id
 *  is prefixed so the same MRN under two clients never collides. */
function billing_patient_key_sql(string $a = 'ci'): string
{
    return "CONCAT($a.client_id, ':', COALESCE(NULLIF(TRIM($a.patient_mrn),''), CONCAT('n:', $a.patient_name, '|', COALESCE($a.patient_dob,''))))";
}

/** How many released charts are still waiting for allocation (optionally scoped). Direct count — not
 *  bounded by the candidate patient cap. */
function billing_unallocated_count(PDO $pdo, array $f = []): int
{
    $w = [billing_released_where('ci')]; $p = [];
    if (!empty($f['client_id']))   { $w[] = "ci.client_id=?";   $p[] = (int) $f['client_id']; }
    if (!empty($f['process_id']))  { $w[] = "ci.process_id=?";  $p[] = (int) $f['process_id']; }
    if (!empty($f['facility_id'])) { $w[] = "ci.facility_id=?"; $p[] = (int) $f['facility_id']; }
    $st = $pdo->prepare("SELECT COUNT(*) FROM coding_inventory ci WHERE " . implode(' AND ', $w) . "
            AND ci.id NOT IN (SELECT inventory_id FROM billing_allocation WHERE allocated_biller IS NOT NULL)");
    $st->execute($p);
    return (int) $st->fetchColumn();
}

/**
 * Import BACKDATED billing inventory from an uploaded Excel (SysAdmin only) — creates coding_inventory charts
 * that are ALREADY released to the internal billing team, so supervisors/managers can allocate them AND the
 * biller sees the CODES to bill. Each row = ONE CPT charge line:
 *   ['client','process','facility','provider','patient','mrn','dob','dos','cpt','units','mods','icds','received'].
 * Rows for the SAME patient + DOS at one client»process»facility»provider are grouped into ONE chart (a claim)
 * carrying all their CPT lines (cpt/units/modifiers/ICDs). Resolves c»p»f»provider by NAME within the Coding
 * verticals (fenced by coding_bulk_coding_client_sql), validates the facility↔provider link, and inserts
 * status='completed', released_to_billing_at=received(or NOW), release_destination='internal', billing_status='pending';
 * coded lines via coding_save_chart_lines(). Returns per-row detail + chart/line counts. Caller enforces sysadmin + audit.
 */
function billing_import_backdated_inventory(PDO $pdo, array $rows, int $actorId): array
{
    $rep = ['created' => 0, 'lines' => 0, 'errors' => [], 'rows' => []];   // created = charts, lines = CPT lines
    $cv = coding_bulk_coding_client_sql('c');
    $findFac = $pdo->prepare("SELECT f.id fid, p.id pid, c.id cid FROM coding_facilities f
        JOIN processes p ON p.id=f.process_id JOIN clients c ON c.id=p.client_id
        WHERE c.deleted_at IS NULL AND p.deleted_at IS NULL AND $cv
          AND (c.name=? OR c.code=?) AND (p.name=? OR p.code=?) AND (f.name=? OR f.code=?) LIMIT 2");
    $findProv = $pdo->prepare("SELECT id FROM coding_providers WHERE name=? LIMIT 2");
    $linked   = $pdo->prepare("SELECT 1 FROM coding_facility_providers WHERE facility_id=? AND provider_id=? LIMIT 1");
    $ins = $pdo->prepare("INSERT INTO coding_inventory
        (client_id,process_id,facility_id,provider_id,patient_mrn,patient_name,patient_dob,dos,status,
         released_to_billing_at,release_destination,billing_status,uploaded_by,submitted_at,created_at,updated_at)
        VALUES (?,?,?,?,?,?,?,?, 'completed', COALESCE(?, NOW()), 'internal', 'pending', ?, NOW(), NOW(), NOW())");
    $record = function ($ln, $client, $proc, $fac, $prov, $patient, $cpt, $status, $err = '') use (&$rep) {
        $rep['rows'][] = ['row'=>$ln, 'client'=>$client, 'process'=>$proc, 'facility'=>$fac, 'provider'=>$prov, 'patient'=>$patient, 'cpt'=>$cpt, 'status'=>$status, 'error'=>$err];
        if ($status === 'error') $rep['errors'][] = "Row $ln: $err";
    };
    $splitCodes = fn($s) => array_values(array_filter(array_map('trim', preg_split('/[,;\s]+/', (string) $s)), fn($x) => $x !== ''));

    // Pass 1 — resolve + validate each row, then GROUP into charts (claims) by patient + DOS at one c»p»f»provider.
    $groups = [];
    foreach ($rows as $n => $r) {
        $ln = (int) $n + 2;
        $client = trim((string)($r['client'] ?? '')); $proc = trim((string)($r['process'] ?? ''));
        $fac = trim((string)($r['facility'] ?? '')); $prov = trim((string)($r['provider'] ?? ''));
        $patient = trim((string)($r['patient'] ?? '')); $mrn = trim((string)($r['mrn'] ?? ''));
        $dob = coding_parse_date($r['dob'] ?? ''); $dos = coding_parse_date($r['dos'] ?? ''); $recv = coding_parse_date($r['received'] ?? '');
        $cpt = strtoupper(trim((string)($r['cpt'] ?? ''))); $units = (int) round((float)($r['units'] ?? 1));
        $mods = $splitCodes($r['mods'] ?? ''); $icds = $splitCodes($r['icds'] ?? '');
        if ($client === '' && $proc === '' && $fac === '' && $prov === '' && $patient === '' && $cpt === '') continue;   // blank row
        if ($patient === '') { $record($ln,$client,$proc,$fac,$prov,$patient,$cpt,'error','patient name is required.'); continue; }
        if ($client === '' || $proc === '' || $fac === '' || $prov === '') { $record($ln,$client,$proc,$fac,$prov,$patient,$cpt,'error','client, process, facility and provider are all required.'); continue; }
        $findFac->execute([$client,$client,$proc,$proc,$fac,$fac]); $fids = $findFac->fetchAll();
        if (!$fids)           { $record($ln,$client,$proc,$fac,$prov,$patient,$cpt,'error',"facility \"$client » $proc » $fac\" not found (Coding)."); continue; }
        if (count($fids) > 1) { $record($ln,$client,$proc,$fac,$prov,$patient,$cpt,'error',"\"$client » $proc » $fac\" is ambiguous."); continue; }
        $findProv->execute([$prov]); $pids = $findProv->fetchAll(PDO::FETCH_COLUMN);
        if (!$pids)           { $record($ln,$client,$proc,$fac,$prov,$patient,$cpt,'error',"provider \"$prov\" not found."); continue; }
        if (count($pids) > 1) { $record($ln,$client,$proc,$fac,$prov,$patient,$cpt,'error',"provider \"$prov\" is ambiguous (rename one)."); continue; }
        $fid = (int) $fids[0]['fid']; $pid = (int) $fids[0]['pid']; $cid = (int) $fids[0]['cid']; $prid = (int) $pids[0];
        $linked->execute([$fid, $prid]);
        if (!$linked->fetchColumn()) { $record($ln,$client,$proc,$fac,$prov,$patient,$cpt,'error',"provider \"$prov\" is not linked to facility \"$fac\" — link it via Hierarchy first."); continue; }
        $pkey = $mrn !== '' ? ('m:' . strtoupper($mrn)) : ('n:' . strtoupper($patient) . '|' . ($dob ?? ''));
        $gkey = "$cid:$pid:$fid:$prid:$pkey:" . ($dos ?? '');
        if (!isset($groups[$gkey])) $groups[$gkey] = [
            'hdr'   => ['cid'=>$cid,'pid'=>$pid,'fid'=>$fid,'prid'=>$prid,'mrn'=>($mrn !== '' ? $mrn : null),'name'=>mb_substr($patient, 0, 200),'dob'=>$dob,'dos'=>$dos,'recv'=>$recv],
            'lines' => [], 'rows' => []];
        if ($cpt !== '') $groups[$gkey]['lines'][] = ['code'=>$cpt, 'units'=>max(1, $units), 'dos'=>$dos, 'icds'=>$icds, 'mods'=>$mods];
        $groups[$gkey]['rows'][] = ['ln'=>$ln,'client'=>$client,'proc'=>$proc,'fac'=>$fac,'prov'=>$prov,'patient'=>$patient,'cpt'=>$cpt];
    }

    // Pass 2 — one chart per group + its coded lines, each in its own transaction.
    foreach ($groups as $g) {
        $h = $g['hdr'];
        try {
            $pdo->beginTransaction();
            $ins->execute([$h['cid'], $h['pid'], $h['fid'], $h['prid'], $h['mrn'], $h['name'], $h['dob'], $h['dos'], $h['recv'], $actorId]);
            $invId = (int) $pdo->lastInsertId();
            // $modAware=true → keep a repeated CPT on the same DOS that differs only by modifier as a distinct billable
            // line (no silent charge loss); the return value is the count of lines actually written.
            if ($g['lines']) { $rep['lines'] += coding_save_chart_lines($pdo, $invId, $g['lines'], true); }
            $pdo->commit();
            $rep['created']++;
            foreach ($g['rows'] as $rr) $record($rr['ln'],$rr['client'],$rr['proc'],$rr['fac'],$rr['prov'],$rr['patient'],$rr['cpt'],'created');
        } catch (Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            foreach ($g['rows'] as $rr) $record($rr['ln'],$rr['client'],$rr['proc'],$rr['fac'],$rr['prov'],$rr['patient'],$rr['cpt'],'error',$e->getMessage());
        }
    }
    return $rep;
}

/** Distribute the released-but-unallocated charts (in $f scope) across $billerIds, round-robin BY PATIENT
 *  (whole-patient unit): ALL charts of one patient go to ONE biller, and a chart is never split across two
 *  billers. A patient who ALREADY has charts with a biller (from a prior run) keeps that same biller, so a
 *  patient is never split across runs either. An already-owned chart is never re-stomped (guarded upsert).
 *  Returns ['allocated'=>charts, 'per_user'=>[], 'patients'=>n]. */
function billing_run_allocation(PDO $pdo, array $f, array $billerIds, int $by): array
{
    $billerIds = array_values(array_intersect(array_filter(array_map('intval', $billerIds)), billing_allocatable_biller_ids($pdo)));
    if (!$billerIds) return ['allocated' => 0, 'per_user' => [], 'patients' => 0];
    $cands = billing_candidate_charts($pdo, $f);
    if (!$cands) return ['allocated' => 0, 'per_user' => [], 'patients' => 0];
    // Group the candidate charts by patient.
    $groups = [];
    foreach ($cands as $c) $groups[(string) $c['pkey']][] = (int) $c['inventory_id'];
    $candKeys = array_keys($groups);
    $allBillers = billing_allocatable_biller_ids($pdo);
    $pkSql = billing_patient_key_sql('ci');
    // Existing owner per CANDIDATE patient → so new charts of that patient stay with them (un-split across
    // runs). Earliest allocation wins (deterministic even if a patient is already split). Scoped to the
    // candidate patients, not a full-table scan.
    $existing = [];
    if ($candKeys) {
        $pin = implode(',', array_fill(0, count($candKeys), '?'));
        $q = $pdo->prepare("SELECT $pkSql AS pkey, a.allocated_biller AS b
                FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
                WHERE a.allocated_biller IS NOT NULL AND $pkSql IN ($pin)
                ORDER BY (a.allocated_at IS NULL), a.allocated_at ASC, a.id ASC");
        $q->execute($candKeys);
        foreach ($q->fetchAll() as $r) if (!isset($existing[(string) $r['pkey']])) $existing[(string) $r['pkey']] = (int) $r['b'];
    }
    // Guarded upsert: an already-OWNED chart is never re-stomped. MySQL evaluates the SET list
    // left-to-right and a column reference reads its value AS-OF that point, so allocated_biller MUST be
    // assigned LAST — otherwise the IF()s below would read the just-written (non-NULL) value and keep
    // stale state (mirrors ar.php). On the unowned branch we also blank the work fingerprint so a chart
    // that was pulled back / RFI'd-then-re-released re-allocates clean (no stale disposition).
    $up = $pdo->prepare("INSERT INTO billing_allocation (inventory_id,allocated_biller,allocated_by,allocated_at,inventory_status)
            VALUES (?,?,?,NOW(),'allocated')
            ON DUPLICATE KEY UPDATE
              allocated_by    =IF(allocated_biller IS NULL,VALUES(allocated_by),allocated_by),
              allocated_at    =IF(allocated_biller IS NULL,NOW(),allocated_at),
              inventory_status=IF(allocated_biller IS NULL,'allocated',inventory_status),
              carrier         =IF(allocated_biller IS NULL,NULL,carrier),
              billing_status  =IF(allocated_biller IS NULL,NULL,billing_status),
              secondary_reason=IF(allocated_biller IS NULL,NULL,secondary_reason),
              comment         =IF(allocated_biller IS NULL,NULL,comment),
              worked_at       =IF(allocated_biller IS NULL,NULL,worked_at),
              last_worked_date=IF(allocated_biller IS NULL,NULL,last_worked_date),
              allocated_biller=IF(allocated_biller IS NULL,VALUES(allocated_biller),allocated_biller)");
    // Re-home a WHOLE patient (incl. their already-owned charts) when their recorded owner is no longer an
    // allocatable biller — otherwise the patient would split between the dead owner (old charts) and a new
    // biller (new charts). Moves every chart of that patient to the replacement.
    $rehome = $pdo->prepare("UPDATE billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
            SET a.allocated_biller=?, a.allocated_by=?, a.allocated_at=NOW() WHERE a.allocated_biller=? AND $pkSql=?");

    $perUser = array_fill_keys($billerIds, 0); $n = 0; $pc = 0; $i = 0; $assign = [];
    $pdo->beginTransaction();
    foreach ($groups as $pkey => $invIds) {
        if (isset($existing[$pkey]) && in_array($existing[$pkey], $allBillers, true)) {
            $u = $existing[$pkey];                                   // keep with the existing valid owner
        } elseif (isset($existing[$pkey])) {
            $u = $billerIds[$i++ % count($billerIds)];               // owner deactivated → re-home whole patient
            $rehome->execute([$u, $by, $existing[$pkey], $pkey]);
            billing_log_event($pdo, null, null, 'reassign', $by, ['from_user'=>$existing[$pkey], 'to_user'=>$u, 'comment'=>'prior owner inactive — patient re-homed']);
        } else {
            $u = $billerIds[$i++ % count($billerIds)];               // brand-new patient → round-robin
        }
        foreach ($invIds as $invId) { $up->execute([(int) $invId, $u, $by]); $assign[(int) $invId] = $u; $n++; }
        $pc++;
    }
    // Batch-resolve allocation ids for the event log (one query instead of an N+1 per chart).
    $idMap = [];
    if ($assign) {
        $iin = implode(',', array_fill(0, count($assign), '?'));
        $q = $pdo->prepare("SELECT inventory_id, id FROM billing_allocation WHERE inventory_id IN ($iin)");
        $q->execute(array_keys($assign));
        foreach ($q->fetchAll() as $r) $idMap[(int) $r['inventory_id']] = (int) $r['id'];
    }
    foreach ($assign as $invId => $u) {
        billing_log_event($pdo, $idMap[$invId] ?? null, $invId, 'allocate', $by, ['to_user' => $u]);
        if (isset($perUser[$u])) $perUser[$u]++;   // $u may be an existing owner outside the selected set
    }
    $pdo->commit();
    audit_log('billing.allocate', 'billing_allocation', null, null, ['charts'=>$n, 'patients'=>$pc, 'billers'=>$billerIds]);
    return ['allocated' => $n, 'per_user' => $perUser, 'patients' => $pc];
}

/** Pull UNTOUCHED charts back (allocated but never worked) so they can be reshuffled. Returns count. */
function billing_pullback_untouched(PDO $pdo, ?int $onlyUser, int $by, ?array $restrictBillers = null): int
{
    $w = ["allocated_biller IS NOT NULL", "worked_at IS NULL", "inventory_status='allocated'"]; $p = [];
    if ($onlyUser) { $w[] = "allocated_biller=?"; $p[] = $onlyUser; }
    // Team fence: a non-sysadmin may only pull back charts owned by billers on their team (null = unrestricted).
    elseif ($restrictBillers !== null) {
        if (!$restrictBillers) return 0;
        $w[] = "allocated_biller IN (" . implode(',', array_map('intval', $restrictBillers)) . ")";
    }
    $sel = $pdo->prepare("SELECT id,inventory_id,allocated_biller FROM billing_allocation WHERE " . implode(' AND ', $w));
    $sel->execute($p); $rows = $sel->fetchAll();
    if (!$rows) return 0;
    foreach ($rows as $r) billing_log_event($pdo, (int) $r['id'], (int) $r['inventory_id'], 'pullback', $by, ['from_user' => (int) $r['allocated_biller']]);
    $ids = implode(',', array_map(fn($r) => (int) $r['id'], $rows));
    $pdo->exec("UPDATE billing_allocation SET allocated_biller=NULL, allocated_by=NULL, allocated_at=NULL, inventory_status=NULL WHERE id IN ($ids)");
    audit_log('billing.pullback', 'billing_allocation', null, null, ['count'=>count($rows)]);
    return count($rows);
}

/** Per-biller EXISTING workload (across all released inventory) so a supervisor picking who to allocate NEW charts
 *  to can see who is already loaded. Keyed by biller id; each entry: total allocated · pending (not yet worked,
 *  inventory_status='allocated') · worked · hold (hold_deductible) · unbilled. Joins coding_inventory for the
 *  released fence so rfi'd/void shells never inflate the backlog. Empty input → []. */
function billing_users_open_load(PDO $pdo, array $billerIds): array
{
    $ids = array_values(array_unique(array_filter(array_map('intval', $billerIds))));
    if (!$ids) return [];
    $in = implode(',', $ids);                                            // ints only — safe to inline
    $rw = billing_released_where('ci');
    $rows = $pdo->query("SELECT a.allocated_biller uid,
                COUNT(*) total,
                COALESCE(SUM(a.inventory_status='allocated'),0)     pending,
                COALESCE(SUM(a.inventory_status='worked'),0)        worked,
                COALESCE(SUM(a.billing_status='hold_deductible'),0) hold,
                COALESCE(SUM(a.billing_status='unbilled'),0)        unbilled
            FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
            WHERE $rw AND a.allocated_biller IN ($in)
            GROUP BY a.allocated_biller")->fetchAll();
    $out = [];
    foreach ($rows as $r) $out[(int) $r['uid']] = [
        'total'  => (int) $r['total'],  'pending' => (int) $r['pending'], 'worked' => (int) $r['worked'],
        'hold'   => (int) $r['hold'],   'unbilled' => (int) $r['unbilled']];
    return $out;
}

/** By-biller breakdown of UNTOUCHED charts (allocated, never worked) that COULD be pulled back — team-scoped
 *  ($restrictBillers null=sysadmin/all). Uses EXACTLY the predicate billing_pullback_untouched acts on
 *  (allocated_biller IS NOT NULL AND worked_at IS NULL AND inventory_status='allocated') so each row's count is
 *  precisely what its pull-back button will reset. Returns ['by_user'=>[{uid,uname,charts}], 'total'=>n]. */
function billing_untouched_breakdown(PDO $pdo, ?array $restrictBillers): array
{
    $rw = billing_released_where('ci');
    $teamSql = coding_team_in_sql($restrictBillers, 'a.allocated_biller');   // '' | ' AND 1=0' | ' AND a.allocated_biller IN(ints)'
    $byUser = $pdo->query("SELECT a.allocated_biller uid, u.name uname, COUNT(*) charts
            FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
            LEFT JOIN users u ON u.id=a.allocated_biller
            WHERE a.allocated_biller IS NOT NULL AND a.worked_at IS NULL AND a.inventory_status='allocated' AND $rw" . $teamSql . "
            GROUP BY a.allocated_biller, u.name ORDER BY charts DESC")->fetchAll();
    return ['by_user' => $byUser, 'total' => array_sum(array_map(fn($r) => (int) $r['charts'], $byUser))];
}

/** Reassign billing_allocation rows to $toUser, or NULL = back to origin (clears owner + work fingerprint).
 *  To a user: a still-unworked chart stays 'allocated' under the new owner; an already-worked chart KEEPS its
 *  'worked' status + disposition (it's done, just re-owned) — never silently demoted back to to-work. */
function billing_reassign(PDO $pdo, array $allocIds, ?int $toUser, int $by): int
{
    $allocIds = array_values(array_filter(array_map('intval', $allocIds)));
    if (!$allocIds) return 0;
    if ($toUser !== null && !in_array((int) $toUser, billing_allocatable_biller_ids($pdo), true)) return 0;
    $in = implode(',', array_fill(0, count($allocIds), '?'));
    $sel = $pdo->prepare("SELECT id,inventory_id,allocated_biller FROM billing_allocation WHERE id IN ($in)");
    $sel->execute($allocIds); $rows = $sel->fetchAll();
    if (!$rows) return 0;
    if ($toUser) {
        $st = $pdo->prepare("UPDATE billing_allocation SET allocated_biller=?, allocated_by=?, allocated_at=NOW(),
                              inventory_status=IF(COALESCE(inventory_status,'')='','allocated',inventory_status) WHERE id IN ($in)");
        $st->execute(array_merge([(int) $toUser, $by], $allocIds));
    } else {
        $st = $pdo->prepare("UPDATE billing_allocation SET allocated_biller=NULL, allocated_by=NULL, allocated_at=NULL, inventory_status=NULL,
                              carrier=NULL, billing_status=NULL, secondary_reason=NULL, comment=NULL, worked_at=NULL, last_worked_date=NULL WHERE id IN ($in)");
        $st->execute($allocIds);
    }
    foreach ($rows as $r)
        billing_log_event($pdo, (int) $r['id'], (int) $r['inventory_id'], $toUser ? 'reassign' : 'pullback', $by,
            ['from_user' => (int) $r['allocated_biller'], 'to_user' => $toUser ?: null]);
    audit_log('billing.reassign', 'billing_allocation', null, null, ['count'=>count($rows), 'to'=>$toUser]);
    return count($rows);
}

/** Reallocate Hold-Deductible / Unbilled charts across $billerIds, round-robin BY PATIENT. The selection is
 *  EXPANDED to every Hold/Unbilled chart of each affected patient, so ALL of one patient's charts move to
 *  the same biller — a partial selection can never split a patient. The prior disposition
 *  (carrier/status/reason/comment) is kept for context; the chart re-enters the assignee's queue
 *  (inventory_status='allocated'). Only hold_deductible & unbilled charts qualify. */
function billing_reallocate(PDO $pdo, array $allocIds, array $billerIds, int $by, ?array $restrictBillers = null): int
{
    $billerIds = array_values(array_intersect(array_filter(array_map('intval', $billerIds)), billing_allocatable_biller_ids($pdo)));
    if (!$billerIds) return 0;
    $allocIds = array_values(array_filter(array_map('intval', $allocIds)));
    if (!$allocIds) return 0;
    // Team fence (null = sysadmin, no restriction): the whole-patient expansion below MUST stay within the
    // actor's team — otherwise reallocating a team chart would sweep in same-patient charts owned by OTHER teams.
    // Applied to BOTH the patient-key discovery and the expansion query so out-of-team charts are never touched.
    if ($restrictBillers !== null) { if (!$restrictBillers) return 0; }
    $rb = $restrictBillers !== null ? " AND a.allocated_biller IN (" . implode(',', array_map('intval', $restrictBillers)) . ")" : "";
    $in = implode(',', array_fill(0, count($allocIds), '?'));
    $pk = billing_patient_key_sql('ci');
    // Distinct patient keys of the SELECTED Hold/Unbilled charts…
    $pkq = $pdo->prepare("SELECT DISTINCT $pk AS pkey FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
        WHERE a.id IN ($in) AND a.billing_status IN ('hold_deductible','unbilled')" . $rb);
    $pkq->execute($allocIds);
    $pkeys = $pkq->fetchAll(PDO::FETCH_COLUMN);
    if (!$pkeys) return 0;
    // …then pull EVERY Hold/Unbilled chart for those patients (expand selection to the whole patient) — still
    // fenced to the actor's team so a cross-team same-patient chart can never be re-homed.
    $pin = implode(',', array_fill(0, count($pkeys), '?'));
    $sel = $pdo->prepare("SELECT a.id, a.inventory_id, a.allocated_biller, $pk AS pkey
        FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
        WHERE $pk IN ($pin) AND a.billing_status IN ('hold_deductible','unbilled')" . $rb . " ORDER BY a.last_worked_date ASC, a.id ASC");
    $sel->execute($pkeys); $rows = $sel->fetchAll();
    if (!$rows) return 0;
    // Decide one biller per patient, then apply to every selected chart of that patient.
    $patientBiller = []; $i = 0;
    foreach ($rows as $r) { $k = (string) $r['pkey']; if (!isset($patientBiller[$k])) $patientBiller[$k] = $billerIds[$i++ % count($billerIds)]; }
    $st = $pdo->prepare("UPDATE billing_allocation SET allocated_biller=?, allocated_by=?, allocated_at=NOW(), inventory_status='allocated' WHERE id=?");
    $n = 0;
    $pdo->beginTransaction();
    foreach ($rows as $r) {
        $u = $patientBiller[(string) $r['pkey']];
        $st->execute([$u, $by, (int) $r['id']]);
        billing_log_event($pdo, (int) $r['id'], (int) $r['inventory_id'], 'reallocate', $by, ['from_user' => (int) $r['allocated_biller'], 'to_user' => $u]);
        $n++;
    }
    $pdo->commit();
    audit_log('billing.reallocate', 'billing_allocation', null, null, ['count'=>$n, 'billers'=>$billerIds]);
    return $n;
}

/** Supervisor raises specific accounts to the CODING team as RFI. The chart is pulled back from billing
 *  and re-enters the coding RFI flow (coding_inventory.status='rfi', released flags cleared) so the coding
 *  Validator/Coder picks it up; the billing allocation is closed and stamped rfi_raised_*. Returns count. */
function billing_raise_rfi(PDO $pdo, array $invIds, int $by, string $reason = ''): int
{
    $invIds = array_values(array_filter(array_map('intval', $invIds)));
    if (!$invIds) return 0;
    $in = implode(',', array_fill(0, count($invIds), '?'));
    // Only charts that are actually released to billing can be raised.
    $sel = $pdo->prepare("SELECT id FROM coding_inventory ci WHERE ci.id IN ($in) AND " . billing_released_where('ci'));
    $sel->execute($invIds); $ok = array_map('intval', $sel->fetchAll(PDO::FETCH_COLUMN));
    if (!$ok) return 0;
    $okIn = implode(',', array_fill(0, count($ok), '?'));
    $pdo->beginTransaction();
    // Send the chart back into coding as an RFI so it lands in the validator's "To review" queue, whose
    // filter is: status IN('rfi','disregard') AND audit_status IS NULL AND released_to_billing_at IS NULL.
    // The chart was audited & released (audit_status='ready_to_deliver'), so we MUST clear the audit verdict
    // (else it stays in limbo, shown by no queue). The billing reason is carried in audit_comment.
    $note = 'Billing RFI' . ($reason !== '' ? ': ' . mb_substr($reason, 0, 2000) : ' raised');
    $pdo->prepare("UPDATE coding_inventory
            SET status='rfi', audit_status=NULL, audit_result=NULL, audit_opportunities=NULL, audit_defects=NULL,
                audit_secondary_reason=NULL, audit_comment=?, auditor_id=NULL, audited_at=NULL, rebuttal_status='none',
                released_to_billing_at=NULL, released_by=NULL, billing_status='pending', updated_at=NOW()
            WHERE id IN ($okIn)")->execute(array_merge([$note], $ok));
    // Close the billing allocation + record the RFI on it and in coding's own event log.
    foreach ($ok as $invId) {
        $aid = billing_alloc_id($pdo, $invId);
        if ($aid) {
            // Fully neutralize the row: clear owner AND the work fingerprint, so if coding re-releases the
            // chart and it is re-allocated later, no stale disposition resurfaces (matches the pullback branch).
            $pdo->prepare("UPDATE billing_allocation SET rfi_raised_at=NOW(), rfi_raised_by=?, inventory_status='rfi_to_coding',
                            allocated_biller=NULL, allocated_by=NULL, allocated_at=NULL,
                            carrier=NULL, billing_status=NULL, secondary_reason=NULL, comment=NULL, worked_at=NULL, last_worked_date=NULL
                            WHERE id=?")->execute([$by, $aid]);
        }
        billing_log_event($pdo, $aid ?: null, $invId, 'rfi_to_coding', $by, ['comment' => mb_substr($reason, 0, 255)]);
        if (function_exists('coding_log_event'))
            coding_log_event($pdo, $invId, 'billing_rfi', $by, $reason ?: 'RFI raised by billing', null, 'rfi');
    }
    $pdo->commit();
    audit_log('billing.rfi_to_coding', 'coding_inventory', null, null, ['ids'=>$ok, 'reason'=>$reason]);
    return count($ok);
}

/** SysAdmin: HARD-DELETE unallocated released-to-billing charts (e.g. a bad backdated-inventory upload). ONLY charts
 *  that are released to billing AND never allocated to a biller are eligible — an allocation shell with
 *  allocated_biller NULL (sent back to origin) still counts as unallocated and may be deleted; anything a biller
 *  owns/worked is skipped. Deletes the chart + every dependent row (coded CPT lines with ICDs/modifiers, audit
 *  findings, chart events, any allocation shell). Mirrors the coding-chart cascade in admin/data-cleanup.php.
 *  Returns ['deleted'=>n,'skipped'=>n]. */
function billing_delete_unallocated_inventory(PDO $pdo, array $invIds, int $by): array
{
    $invIds = array_values(array_unique(array_filter(array_map('intval', $invIds))));
    $res = ['deleted'=>0, 'skipped'=>0];
    if (!$invIds) return $res;
    $in = implode(',', array_fill(0, count($invIds), '?'));
    $sel = $pdo->prepare("SELECT ci.id FROM coding_inventory ci
        WHERE ci.id IN ($in) AND " . billing_released_where('ci') . "
          AND NOT EXISTS (SELECT 1 FROM billing_allocation a WHERE a.inventory_id=ci.id AND a.allocated_biller IS NOT NULL)");
    $sel->execute($invIds);
    $eligible = array_map('intval', $sel->fetchAll(PDO::FETCH_COLUMN));
    $res['skipped'] = count($invIds) - count($eligible);
    if (!$eligible) return $res;

    $tableExists = function (string $t) use ($pdo): bool { try { $pdo->query("SELECT 1 FROM `$t` LIMIT 1"); return true; } catch (Throwable $e) { return false; } };
    $hasBcc = $tableExists('billing_chart_cpt'); $hasBae = $tableExists('billing_allocation_event');
    $pdo->beginTransaction();
    try {
        foreach ($eligible as $id) {
            if ($hasBcc) $pdo->prepare("DELETE FROM billing_chart_cpt WHERE inventory_id=?")->execute([$id]);
            if ($hasBae) {
                try { $pdo->prepare("DELETE e FROM billing_allocation_event e JOIN billing_allocation a ON a.id=e.allocation_id WHERE a.inventory_id=?")->execute([$id]); } catch (Throwable $e) {}
                // Also purge events that carry the chart's inventory_id but a NULL/stale allocation_id (e.g. rfi_to_coding).
                try { $pdo->prepare("DELETE FROM billing_allocation_event WHERE inventory_id=?")->execute([$id]); } catch (Throwable $e) {}
            }
            try { $pdo->prepare("DELETE ap FROM billing_audit_param ap JOIN billing_allocation a ON a.id=ap.allocation_id WHERE a.inventory_id=?")->execute([$id]); } catch (Throwable $e) {}
            $pdo->prepare("DELETE FROM billing_allocation WHERE inventory_id=?")->execute([$id]);
            $pdo->prepare("DELETE FROM coding_audit_codes WHERE inventory_id=?")->execute([$id]);
            $pdo->prepare("DELETE FROM coding_chart_events WHERE inventory_id=?")->execute([$id]);
            $pdo->prepare("DELETE FROM coding_chart_cpt_icds WHERE chart_cpt_id IN (SELECT id FROM coding_chart_cpts WHERE inventory_id=?)")->execute([$id]);
            $pdo->prepare("DELETE FROM coding_chart_cpt_modifiers WHERE chart_cpt_id IN (SELECT id FROM coding_chart_cpts WHERE inventory_id=?)")->execute([$id]);
            $pdo->prepare("DELETE FROM coding_chart_cpts WHERE inventory_id=?")->execute([$id]);
            $pdo->prepare("DELETE FROM coding_inventory WHERE id=?")->execute([$id]);
            $res['deleted']++;
        }
        $pdo->commit();
    } catch (Throwable $e) { $pdo->rollBack(); throw $e; }
    audit_log('billing.inventory.delete_unalloc', 'coding_inventory', null, null, ['ids'=>$eligible, 'count'=>count($eligible)]);
    return $res;
}

/* ---------------------------------------------------------------- biller portal (My billing queue) */

/** First chart of the NEXT patient for a biller to work: owned + still 'allocated'. Flow order is
 *  Facility » Provider » Patient » DOS (then oldest), so a biller works a facility's charts together and a
 *  patient's visits oldest-first. Pass $exclPatientKey to SKIP the current patient (the "Get next patient"
 *  action). The provider matches what the queue shows (coded provider, falling back to the uploaded one). */
function billing_next_chart_for_biller(PDO $pdo, int $uid, ?int $exclude = null, ?string $exclPatientKey = null): ?int
{
    $pk = billing_patient_key_sql('ci');
    $sql = "SELECT a.inventory_id
            FROM billing_allocation a
            JOIN coding_inventory ci ON ci.id=a.inventory_id
            LEFT JOIN coding_facilities f ON f.id=ci.facility_id
            LEFT JOIN coding_providers pr ON pr.id=COALESCE(ci.coded_provider_id, ci.provider_id)
            WHERE a.allocated_biller=? AND a.inventory_status='allocated'"
            . ($exclude ? " AND a.inventory_id<>" . (int) $exclude : "")
            . ($exclPatientKey !== null ? " AND $pk<>?" : "")
            . " ORDER BY (f.name IS NULL), f.name ASC, (pr.name IS NULL), pr.name ASC, ci.patient_name ASC, ci.dos ASC, a.allocated_at ASC, a.id ASC
            LIMIT 1";
    $st = $pdo->prepare($sql);
    $params = [$uid]; if ($exclPatientKey !== null) $params[] = $exclPatientKey;
    $st->execute($params);
    $v = $st->fetchColumn();
    return $v ? (int) $v : null;
}

/** The patient grouping key for one chart (client:MRN, or client:name|dob when MRN is blank) — used to walk
 *  a single patient's visits one DOS at a time before moving on to the next patient. */
function billing_patient_key_of(PDO $pdo, int $invId): ?string
{
    $pk = billing_patient_key_sql('ci');
    $st = $pdo->prepare("SELECT $pk FROM coding_inventory ci WHERE ci.id=?");
    $st->execute([$invId]);
    $v = $st->fetchColumn();
    return ($v === false || $v === null) ? null : (string) $v;
}

/** Next UNWORKED visit (chart) of the SAME patient for this biller, oldest DOS first; null when the patient
 *  has no more visits to work (the biller then pulls the next patient). */
function billing_next_visit_for_patient(PDO $pdo, int $uid, string $patientKey, int $excludeInvId): ?int
{
    $pk = billing_patient_key_sql('ci');
    $st = $pdo->prepare("SELECT a.inventory_id FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
        WHERE a.allocated_biller=? AND a.inventory_status='allocated' AND a.inventory_id<>? AND $pk=?
        ORDER BY ci.dos ASC, a.id ASC LIMIT 1");
    $st->execute([$uid, $excludeInvId, $patientKey]);
    $v = $st->fetchColumn();
    return $v ? (int) $v : null;
}

/** How many MORE unworked visits this patient has for the biller (excluding the chart currently open). */
function billing_patient_remaining_visits(PDO $pdo, int $uid, string $patientKey, int $excludeInvId): int
{
    $pk = billing_patient_key_sql('ci');
    $st = $pdo->prepare("SELECT COUNT(*) FROM billing_allocation a JOIN coding_inventory ci ON ci.id=a.inventory_id
        WHERE a.allocated_biller=? AND a.inventory_status='allocated' AND a.inventory_id<>? AND $pk=?");
    $st->execute([$uid, $excludeInvId, $patientKey]);
    return (int) $st->fetchColumn();
}

/** True when EVERY effective coded line of the chart has a billed-code choice (CPT or ALF) in $billedSel.
 *  Gates saving a Billed / On-hold-deductible claim: the biller must record which code was billed on each line.
 *  A chart with no coded lines passes (nothing to bill). $billedSel is line_key => 'cpt'|'alf'. */
function billing_billable_selection_ok(PDO $pdo, int $invId, array $billedSel, string $carrier = ''): bool
{
    $lines = coding_effective_lines_bulk($pdo, [$invId])[$invId] ?? [];
    if (!$lines) return true;
    // ALF is a single per-visit code — an ALF pick on ANY line bills the whole encounter, so one is enough (the biller
    // can bill just the ALF and save). Otherwise (CPT / fee-for-service) every line must record which code was billed.
    $anyAlf = false; $anyCode = false; $allHaveCode = true;
    foreach ($lines as $i => $ln) {
        $t = $billedSel[billing_line_key($ln, (int) $i)] ?? '';
        if     ($t === 'alf') { $anyAlf = true; $anyCode = true; }
        elseif ($t === 'cpt') { $anyCode = true; }
        else                  { $allHaveCode = false; }
    }
    // MEDICAID: G/M (HCPCS) codes aren't billable on some Medicaid plans, so the biller intentionally bills only the
    // eligible (numeric-CPT) lines — a SUBSET is valid as long as at least one line is coded. All other carriers keep
    // the strict rule: every line coded, OR an ALF billing the whole encounter.
    if ($carrier !== '' && stripos($carrier, 'medicaid') !== false) return $anyCode;
    return $anyAlf || $allHaveCode;
}

/** Stable per-line key for a chart's effective CPT line: 'c'+coding_chart_cpts.id for a real coded line,
 *  'x'+index for an audit-added (synthetic) line with no real id. Must be computed identically on save
 *  and on render so a saved "billed" tick maps back to the same line. */
function billing_line_key(array $line, int $index): string
{
    $id = (int) ($line['cptId'] ?? 0);
    return $id > 0 ? ('c' . $id) : ('x' . $index);
}

/** Per-chart "billed" CPT selection → [invId => ['keys'=>[line_key=>1,...], 'cpts'=>['99213',...]]].
 *  Powers the work-card pre-ticks and the queue's "Billed" column. */
function billing_billed_lines_bulk(PDO $pdo, array $invIds): array
{
    $invIds = array_values(array_unique(array_filter(array_map('intval', $invIds))));
    $out = [];
    if (!$invIds) return $out;
    $in = implode(',', $invIds);
    try {
        $rows = $pdo->query("SELECT inventory_id, line_key, cpt_code, billed_code, billed_code_type FROM billing_chart_cpt
                             WHERE billed=1 AND inventory_id IN ($in) ORDER BY dos, id")->fetchAll();
    } catch (Throwable $e) { return $out; }   // table/columns may not exist before the migration runs
    foreach ($rows as $r) {
        $iid  = (int) $r['inventory_id'];
        if (!isset($out[$iid])) $out[$iid] = ['keys' => [], 'codes' => []];
        $type = (($r['billed_code_type'] ?? '') === 'alf') ? 'alf' : 'cpt';
        $code = (($r['billed_code'] ?? '') !== '') ? (string) $r['billed_code'] : (string) ($r['cpt_code'] ?? '');
        $out[$iid]['keys'][(string) $r['line_key']] = ['type' => $type, 'code' => $code];   // pre-select the radio
        if ($code !== '') $out[$iid]['codes'][] = ['code' => $code, 'type' => $type];        // queue "Billed" column
    }
    return $out;
}

/** Save a biller's disposition for a chart they own. Validates status + that 'unbilled' carries a reason.
 *  $billedSel = [line_key => 'cpt'|'alf'] — per coded line, WHICH of the CPT/ALF pair the biller finalized &
 *  billed (validated against the chart's real effective lines). Tracks first vs last (re)worker + work_count.
 *  Returns true on success. Ownership enforced (allocated_biller must equal $uid). */
function billing_save_work(PDO $pdo, int $invId, int $uid, string $carrier, string $status, string $reason, string $comment, array $billedSel = []): bool
{
    if (!isset(BILLING_WORK_STATUSES[$status])) return false;
    $st = $pdo->prepare("SELECT id, rebuttal_status FROM billing_allocation WHERE inventory_id=? AND allocated_biller=?");
    $st->execute([$invId, $uid]); $r = $st->fetch();
    if (!$r) return false;                                     // not theirs / not allocated
    if (($r['rebuttal_status'] ?? 'none') === 'raised') return false;  // locked while a rebuttal awaits the auditor
    $aid = (int) $r['id'];
    $reason = ($status === 'unbilled') ? mb_substr($reason, 0, 200) : '';   // secondary reason only for Unbilled
    // Fresh work re-enters QA: flag into the audit sample at the configured rate and clear any prior audit
    // verdict / rebuttal (the chart changed, so an old audit no longer applies). first_worked_* is set once
    // (COALESCE), worked_by tracks the latest (re)worker, and work_count increments on every save.
    $inSample = billing_audit_sample_decision($pdo, $uid);   // per-biller sample % (override or the global rate)
    $pdo->prepare("UPDATE billing_allocation
            SET carrier=?, billing_status=?, secondary_reason=?, comment=?, worked_at=NOW(), last_worked_date=CURDATE(), inventory_status='worked',
                first_worked_by=COALESCE(first_worked_by, ?), first_worked_at=COALESCE(first_worked_at, NOW()), worked_by=?, work_count=work_count+1,
                in_sample=?, audit_result=NULL, audit_opportunities=NULL, audit_defects=NULL, audit_error_category=NULL, audit_comment=NULL,
                auditor_id=NULL, audited_at=NULL, rebuttal_status='none', rebuttal_comment=NULL, rebuttal_at=NULL, rebuttal_resolved_at=NULL
            WHERE id=?")
        ->execute([mb_substr($carrier, 0, 200) ?: null, $status, $reason ?: null, mb_substr($comment, 0, 255) ?: null, $uid, $uid, $inSample, $aid]);
    billing_drop_audit_params($pdo, $aid);   // the chart changed → its prior per-param audit no longer applies

    // Persist WHICH code (CPT or ALF) was billed per line. Recompute the chart's real effective lines so we only
    // store valid, server-validated keys (never trust the POST) and snapshot the chosen code + DOS for reporting.
    try {
        $lines  = coding_effective_lines_bulk($pdo, [$invId])[$invId] ?? [];
        $alfMap = function_exists('coding_alf_map') ? coding_alf_map($pdo) : [];   // [cpt => alf]
        $valid  = [];
        foreach ($lines as $i => $ln) $valid[billing_line_key($ln, (int) $i)] = $ln;
        $pdo->prepare("DELETE FROM billing_chart_cpt WHERE inventory_id=?")->execute([$invId]);
        if ($billedSel) {
            $ins = $pdo->prepare("INSERT INTO billing_chart_cpt (inventory_id,line_key,chart_cpt_id,cpt_code,billed_code,billed_code_type,dos,billed,billed_by,billed_at)
                                  VALUES (?,?,?,?,?,?,?,1,?,NOW())");
            foreach ($billedSel as $k => $type) {
                $k = (string) $k;
                if (!isset($valid[$k])) continue;
                $type = ($type === 'alf') ? 'alf' : 'cpt';
                $ln   = $valid[$k];
                $cpt  = (string) ($ln['cpt'] ?? '');
                $alf  = (string) ($alfMap[$cpt] ?? '');
                if ($type === 'alf' && $alf === '') $type = 'cpt';     // no ALF mapped for this CPT → bill the CPT
                $code = $type === 'alf' ? $alf : $cpt;
                if ($code === '') continue;
                $cid = (int) ($ln['cptId'] ?? 0) ?: null;
                $ins->execute([$invId, $k, $cid, mb_substr($cpt, 0, 20) ?: null, mb_substr($code, 0, 20), $type, ($ln['dos'] ?? null) ?: null, $uid]);
            }
        }
    } catch (Throwable $e) { /* per-line billed is additive; never block the core save */ }

    billing_log_event($pdo, $aid, $invId, 'work', $uid, ['billing_status'=>$status, 'carrier'=>$carrier, 'secondary_reason'=>$reason, 'comment'=>$comment]);
    return true;
}

/* ---------------------------------------------------------------- billing AUDIT (QA / DPO) */

/** Audit sample rate (% of worked charts flagged for QA). Stored in settings 'billing.audit_sample_pct'. */
function billing_audit_sample_pct(PDO $pdo): int
{
    try {
        $st = $pdo->prepare("SELECT value FROM settings WHERE `key`='billing.audit_sample_pct'");
        $st->execute(); $v = $st->fetchColumn();
        if ($v !== false && $v !== null && $v !== '') return max(0, min(100, (int) $v));
    } catch (Throwable $e) {}
    return BILLING_AUDIT_SAMPLE_DEFAULT;
}

/** Set the audit sample rate (0–100). */
function billing_set_audit_sample_pct(PDO $pdo, int $pct, int $by): void
{
    $pct = max(0, min(100, $pct));
    $ex = $pdo->prepare("SELECT 1 FROM settings WHERE `key`='billing.audit_sample_pct'"); $ex->execute();
    if ($ex->fetchColumn()) {
        $pdo->prepare("UPDATE settings SET value=?, value_type='int', group_name='billing', updated_by=?, updated_at=NOW() WHERE `key`='billing.audit_sample_pct'")
            ->execute([(string) $pct, $by]);
    } else {
        $pdo->prepare("INSERT INTO settings (`key`,`value`,`value_type`,`group_name`,updated_by,updated_at)
                       VALUES ('billing.audit_sample_pct',?,'int','billing',?,NOW())")->execute([(string) $pct, $by]);
    }
}

/** The effective per-biller audit sample %: a per-user override if one is set, else the global rate. */
function billing_user_sample_pct(PDO $pdo, int $uid): int
{
    try {
        $st = $pdo->prepare("SELECT sample_pct FROM billing_user_sample WHERE user_id=?"); $st->execute([$uid]);
        $p = $st->fetchColumn(); if ($p !== false) return max(0, min(100, (int) $p));
    } catch (Throwable $e) {}
    return billing_audit_sample_pct($pdo);           // no override → fall back to the overall rate
}

/** True if a biller has an explicit per-user sample override (vs. inheriting the global rate). */
function billing_user_has_sample_override(PDO $pdo, int $uid): bool
{
    try { $st = $pdo->prepare("SELECT 1 FROM billing_user_sample WHERE user_id=?"); $st->execute([$uid]);
        return (bool) $st->fetchColumn(); } catch (Throwable $e) { return false; }
}

/** Supervisor/manager sets a biller's per-user audit sample % (0–100). */
function billing_set_user_sample(PDO $pdo, int $uid, int $pct, int $by): void
{
    $pct = max(0, min(100, $pct));
    $pdo->prepare("INSERT INTO billing_user_sample (user_id,sample_pct,updated_by) VALUES (?,?,?)
                   ON DUPLICATE KEY UPDATE sample_pct=VALUES(sample_pct), updated_by=VALUES(updated_by)")->execute([$uid, $pct, $by]);
}

/** Should a just-worked chart enter the audit sample for this biller? (1/0) — uses the biller's effective %. */
function billing_audit_sample_decision(PDO $pdo, int $uid): int
{
    $pct = billing_user_sample_pct($pdo, $uid);
    if ($pct >= 100) return 1;
    if ($pct <= 0)   return 0;
    return random_int(1, 100) <= $pct ? 1 : 0;
}

/** Rolling DPO accuracy % for a biller over the last $days (from audited charts they worked). Null = nothing audited. */
function billing_user_quality_pct(PDO $pdo, int $uid, int $days = 21): ?int
{
    $days = max(1, min(365, $days));
    $st = $pdo->prepare("SELECT COALESCE(SUM(audit_opportunities),0) opp, COALESCE(SUM(audit_defects),0) def
        FROM billing_allocation
        WHERE worked_by=? AND audit_result IS NOT NULL AND audited_at >= (NOW() - INTERVAL $days DAY)");
    $st->execute([$uid]); $r = $st->fetch();
    $opp = (int) ($r['opp'] ?? 0); if ($opp <= 0) return null;
    $def = (int) ($r['def'] ?? 0);
    return (int) round(max(0, ($opp - $def)) / $opp * 100);
}

/** Save (or re-save) the audit of a worked chart. $allocId = billing_allocation.id. $params keyed by
 *  BILLING_AUDIT_PARAMS key → ['result'=>'pass'|'fail','note'=>...]. $flagged = the advisory "Flag only" 6th item —
 *  it does NOT count toward opportunities/defects (quality is only the 5 scored params) but still routes the chart to
 *  the biller. Writes the DPO ROLLUP back onto billing_allocation (audit_result/opportunities=5/defects + a failed-
 *  param category summary for back-compat) AND upserts the 5 billing_audit_param child rows; (re)opens the rebuttal
 *  TAT and resets any prior rebuttal. Returns the allocation id (0 on failure). Rebuttal-locked over an OPEN dispute. */
function billing_save_audit(PDO $pdo, int $allocId, int $auditorId, array $params, string $comment, bool $flagged = false): int
{
    $a = $pdo->prepare("SELECT id, rebuttal_status FROM billing_allocation WHERE id=?");
    $a->execute([$allocId]); $a = $a->fetch();
    if (!$a) return 0;
    if (($a['rebuttal_status'] ?? 'none') === 'raised') return 0;   // never wipe an open dispute
    $opp = count(BILLING_AUDIT_PARAMS); $def = 0; $failLabels = [];
    foreach (BILLING_AUDIT_PARAMS as $k => $label) if ((($params[$k]['result'] ?? 'pass')) === 'fail') { $def++; $failLabels[] = $label; }
    // A defect OR the advisory flag makes the chart route to the biller; the flag never adds to def (no quality hit).
    $result  = ($def > 0 || $flagged) ? 'error' : 'pass';
    $catSum  = $failLabels ? mb_substr(implode('; ', $failLabels), 0, 120) : ($flagged ? 'Flagged (advisory)' : null);
    $due     = date('Y-m-d', strtotime('+' . BILLING_REBUTTAL_TAT_DAYS . ' days'));
    $pdo->beginTransaction();
    $pdo->prepare("UPDATE billing_allocation SET audit_result=?, audit_opportunities=?, audit_defects=?, audit_flagged=?,
            audit_error_category=?, audit_comment=?, auditor_id=?, audited_at=NOW(),
            rebuttal_status='none', rebuttal_comment=NULL, rebuttal_at=NULL, rebuttal_resolved_at=NULL, rebuttal_due=?
        WHERE id=?")
        ->execute([$result, $opp, $def, $flagged ? 1 : 0, $catSum, $comment ?: null, $auditorId, $due, $allocId]);
    $up = $pdo->prepare("INSERT INTO billing_audit_param (allocation_id,param_key,result,note) VALUES (?,?,?,?)
                         ON DUPLICATE KEY UPDATE result=VALUES(result), note=VALUES(note), disputed=0, dispute_reason=NULL, rebuttal_result=NULL");
    foreach (BILLING_AUDIT_PARAMS as $k => $_) {
        $r = ((($params[$k]['result'] ?? 'pass')) === 'fail') ? 'fail' : 'pass';
        $up->execute([$allocId, $k, $r, trim((string) ($params[$k]['note'] ?? '')) ?: null]);
    }
    $pdo->commit();
    return $allocId;
}

/** Recompute an audit's opportunities/defects/result (and the failed-param category summary) from its child rows
 *  after a per-param rebuttal overturn. Mirrors ar_recompute_audit. */
function billing_recompute_audit(PDO $pdo, int $allocId): void
{
    $st = $pdo->prepare("SELECT COUNT(*) opp, SUM(result<>'pass') def, MAX(a.audit_flagged) flagged
        FROM billing_audit_param p JOIN billing_allocation a ON a.id=p.allocation_id WHERE p.allocation_id=?");
    $st->execute([$allocId]); $r = $st->fetch();
    $opp = (int) ($r['opp'] ?? 0); $def = (int) ($r['def'] ?? 0); $flagged = (int) ($r['flagged'] ?? 0);
    // A LEGACY audit (scored under the old single-verdict model, before this feature) has NO child rows — leave its
    // stored rollup (audit_result/opportunities/defects) untouched instead of wiping it to NULL on a rebuttal resolve.
    if ($opp === 0) return;
    $cat = null;
    if ($def > 0) {
        $fs = $pdo->prepare("SELECT param_key FROM billing_audit_param WHERE allocation_id=? AND result='fail'");
        $fs->execute([$allocId]);
        $labels = array_map(fn($k) => BILLING_AUDIT_PARAMS[$k] ?? $k, $fs->fetchAll(PDO::FETCH_COLUMN));
        $cat = $labels ? mb_substr(implode('; ', $labels), 0, 120) : null;
    } elseif ($flagged) { $cat = 'Flagged (advisory)'; }
    // A flag keeps the chart routed to the biller (error) even after every quality defect is overturned.
    $pdo->prepare("UPDATE billing_allocation SET audit_opportunities=?, audit_defects=?, audit_result=?, audit_error_category=? WHERE id=?")
        ->execute([$opp, $def, ($def > 0 || $flagged) ? 'error' : 'pass', $cat, $allocId]);
}

/** Drop the per-param audit child rows for an allocation (call before deleting/resetting the allocation row). */
function billing_drop_audit_params(PDO $pdo, int $allocId): void
{
    $pdo->prepare("DELETE FROM billing_audit_param WHERE allocation_id=?")->execute([$allocId]);
}

/**
 * Biller raises a rebuttal on an advisory-FLAGGED audit error on one of THEIR OWN charts (ownership fenced to
 * allocated_biller=$uid). Only a flagged error still in 'worked' status is rebuttable; a sent-back chart is
 * reworked, not rebutted. Notifies the auditor who issued the verdict. Returns true if a rebuttal was raised.
 */
function billing_raise_rebuttal(PDO $pdo, int $invId, int $uid, string $note, array $disputed = []): bool
{
    // $disputed: [param_key => reason] the biller is contesting. Empty = whole-chart rebut → dispute ALL failed
    // params with the shared note (used by the my-billing inline rebut). Ownership + state fenced to the biller's
    // own flagged error still in 'worked'. Marks only result='fail' child rows; an EXPLICIT dispute of only
    // forged/passed keys rolls back (nothing to contest).
    if (trim($note) === '') return false;
    $pdo->beginTransaction();
    $st = $pdo->prepare("UPDATE billing_allocation SET rebuttal_status='raised', rebuttal_comment=?, rebuttal_at=NOW()
            WHERE inventory_id=? AND allocated_biller=? AND audit_result='error' AND inventory_status='worked' AND rebuttal_status IN ('none','rejected')");
    $st->execute([mb_substr($note, 0, 1000), $invId, $uid]);
    if (!$st->rowCount()) { $pdo->rollBack(); return false; }
    $aid = (int) (function () use ($pdo, $invId, $uid) {
        $q = $pdo->prepare("SELECT id FROM billing_allocation WHERE inventory_id=? AND allocated_biller=?"); $q->execute([$invId, $uid]); return $q->fetchColumn();
    })();
    $pdo->prepare("UPDATE billing_audit_param SET disputed=0, dispute_reason=NULL, rebuttal_result=NULL WHERE allocation_id=?")->execute([$aid]);
    $explicit = !empty($disputed);
    if (!$disputed) {   // whole-chart: contest every failed param with the shared note
        $fs = $pdo->prepare("SELECT param_key FROM billing_audit_param WHERE allocation_id=? AND result='fail'"); $fs->execute([$aid]);
        foreach ($fs->fetchAll(PDO::FETCH_COLUMN) as $k) $disputed[$k] = $note;
    }
    $mark = $pdo->prepare("UPDATE billing_audit_param SET disputed=1, dispute_reason=?, rebuttal_result='pending' WHERE allocation_id=? AND param_key=? AND result='fail'");
    $marked = 0;
    foreach ($disputed as $k => $reason) {
        if (!isset(BILLING_AUDIT_PARAMS[$k])) continue;
        $mark->execute([mb_substr(trim((string) $reason), 0, 255) ?: null, $aid, (string) $k]); $marked += $mark->rowCount();
    }
    // Reject a dispute that names only forged/passed params. A legacy audit (no child rows yet) still stands on the
    // header 'raised' — only roll back when the biller EXPLICITLY named params and none were valid failed ones.
    if ($explicit && $marked === 0) { $pdo->rollBack(); return false; }
    $pdo->commit();
    $a = $pdo->prepare("SELECT ba.auditor_id, c.patient_name FROM billing_allocation ba
                        LEFT JOIN coding_inventory c ON c.id=ba.inventory_id WHERE ba.inventory_id=? AND ba.allocated_biller=?");
    $a->execute([$invId, $uid]); $a = $a->fetch();
    if ($a && (int) ($a['auditor_id'] ?? 0) > 0 && (int) $a['auditor_id'] !== $uid)
        notify((int) $a['auditor_id'], 'billing', 'Audit rebuttal raised',
            (($a['patient_name'] ?: 'a chart')) . ' — a biller disputed your billing audit. Review it in the Rebuttals queue.', '/admin/billing-audit.php?view=rebuttals');
    return true;
}

/** DPO accuracy % from opportunities/defects (null when no opportunities). */
function billing_audit_accuracy(?int $opp, ?int $def): ?float
{
    $opp = (int) $opp; $def = (int) $def;
    return $opp > 0 ? round(100.0 * ($opp - max(0, $def)) / $opp, 1) : null;
}
