<?php
/**
 * Slice 8i-DocNumbers · central atomic document numbering · end-to-end.
 *
 *  1) Every seeded doc_key exists + is peek-formattable
 *  2) allocate() is monotonic + returns the peeked value
 *  3) 100 rapid allocations produce 100 distinct sequential numbers (atomicity)
 *  4) YEARLY reset trips when current_year mismatches
 *  5) MONTHLY reset trips when current_month mismatches
 *  6) NEVER reset never trips
 *  7) year_position variants format correctly (AFTER_PREFIX vs AFTER_SEQ)
 *  8) Guards: unknown doc_key throws; seq_width out of range refused; start_no < 1 refused
 *  9) configure() edits stick
 * 10) Wired callers (Lpos::nextLpoNo SUPPLIER, PaymentVouchers::nextPvNo/nextPaymentNo,
 *     Receipts::nextReceiptNo, Sales::nextSaleNo, WorksCrs::draft, GoodsScrubPack, GoodsOnboarding CPO)
 *     all pull from DocNumberSettings
 */
define('DS', DIRECTORY_SEPARATOR);
define('ROOT', dirname(__DIR__));
require_once ROOT . DS . 'config' . DS . 'config.php';
require_once ROOT . DS . 'core' . DS . 'Application.php';
require_once ROOT . DS . 'core' . DS . 'Model.php';
require_once ROOT . DS . 'core' . DS . 'DB.php';
$_SESSION = $_SESSION ?? [];
foreach (['DocNumberSettings.php','Lpos.php','PaymentVouchers.php','Receipts.php','Sales.php'] as $m) {
    require_once ROOT . DS . 'app' . DS . 'models' . DS . $m;
}

function h(string $t): void { echo "\n==== $t ====\n"; }
function p(string $t): void { echo "  $t\n"; }
function ok(bool $c, string $m): void { echo ($c ? "  ✓ " : "  ✗ ") . $m . "\n"; }
function boom(string $why): void { echo "\nFATAL: $why\n"; exit(1); }

$db  = new DB();
$dns = new DocNumberSettings();

/* ── 0. Snapshot every seeded row (for restore later) ──────────────── */
h('0. Snapshot seed state');
$snap = [];
foreach ($dns->list() as $r) $snap[$r['doc_key']] = $r;
p("seeded doc_keys: " . implode(', ', array_keys($snap)));
$expectedKeys = ['LPO','PV','PAYMENT','CR','IPC','SCRUB','CLIENT_PO','SALE','RECEIPT','PROJECT'];
foreach ($expectedKeys as $k) ok(isset($snap[$k]), "seed row present: $k");

/* ── 1. peek + allocate — monotonic ────────────────────────────────── */
h('1. peek + allocate monotonic');
$before = $dns->peek('LPO');
$one    = $dns->allocate('LPO');
$two    = $dns->allocate('LPO');
p("peek before → $before · allocated $one then $two");
ok($one === $before, 'allocate returns what peek showed (before someone else took it)');
$oneSeq = (int)preg_replace('/[^0-9]/', '', substr($one, -6));
$twoSeq = (int)preg_replace('/[^0-9]/', '', substr($two, -6));
ok($twoSeq === $oneSeq + 1, 'seq is monotonic + strictly incrementing');

/* ── 2. Atomicity — 100 rapid allocations ──────────────────────────── */
h('2. 100 rapid allocations produce distinct numbers');
$seen = [];
for ($i = 0; $i < 100; $i++) $seen[] = $dns->allocate('PROJECT');
ok(count($seen) === 100,                                          '100 calls returned 100 strings');
ok(count(array_unique($seen)) === 100,                             '100 unique values (no duplicates)');
$seqs = array_map(fn($s) => (int)substr(strrchr($s, '-'), 1), $seen);
$sortedAsc = $seqs; sort($sortedAsc);
ok($seqs === $sortedAsc,                                           'sequence strictly ascending (no reordering)');

/* ── 3. YEARLY reset ───────────────────────────────────────────────── */
h('3. YEARLY reset auto-trips when current_year mismatches');
$db->insertUpdate("UPDATE doc_number_settings SET reset_scope='YEARLY', current_year=2020, next_no=5000, start_no=1 WHERE doc_key='LPO'", []);
$rst = $dns->allocate('LPO');
$rstSeq = (int)substr(strrchr($rst, '-'), 1);
ok($rstSeq === 1,                                                 'YEARLY: post-reset seq = start_no (1)');
$row = $dns->findByKey('LPO');
ok((int)$row['current_year'] === (int)date('Y'),                   'current_year updated to now');
ok((int)$row['next_no'] === 2,                                     'next_no = 2 after first post-reset allocation');

/* ── 4. MONTHLY reset ─────────────────────────────────────────────── */
h('4. MONTHLY reset auto-trips when current_month mismatches');
$db->insertUpdate("UPDATE doc_number_settings SET reset_scope='MONTHLY', current_year=" . date('Y') . ", current_month=" . (((int)date('n') % 12) + 1) . ", next_no=7000 WHERE doc_key='PV'", []);
$mrst = $dns->allocate('PV');
$mSeq = (int)substr(strrchr($mrst, '-'), 1);
ok($mSeq === 1,                                                    'MONTHLY: post-reset seq = 1');
$mrow = $dns->findByKey('PV');
ok((int)$mrow['current_month'] === (int)date('n'),                  'current_month updated to now');

/* ── 5. NEVER reset does not trip ──────────────────────────────────── */
h('5. NEVER reset does not trip');
$db->insertUpdate("UPDATE doc_number_settings SET reset_scope='NEVER', current_year=1999, next_no=99998 WHERE doc_key='PROJECT'", []);
$nrst = $dns->allocate('PROJECT');
$nSeq = (int)substr(strrchr($nrst, '-'), 1);
ok($nSeq === 99998,                                                'NEVER: seq unchanged by year mismatch');

/* ── 6. year_position variants ─────────────────────────────────────── */
h('6. year_position format variants');
// AFTER_PREFIX (default): PFX-YYYY-####
$db->insertUpdate("UPDATE doc_number_settings SET reset_scope='NEVER', year_position='AFTER_PREFIX', next_no=42, prefix='TST', include_year=1 WHERE doc_key='RECEIPT'", []);
$fAfterPrefix = $dns->peek('RECEIPT');
ok(preg_match('/^TST-\d{4}-0042$/', $fAfterPrefix) === 1,           "AFTER_PREFIX format: $fAfterPrefix");
// AFTER_SEQ: PFX-####-YYYY
$db->insertUpdate("UPDATE doc_number_settings SET year_position='AFTER_SEQ' WHERE doc_key='RECEIPT'", []);
$fAfterSeq = $dns->peek('RECEIPT');
ok(preg_match('/^TST-0042-\d{4}$/', $fAfterSeq) === 1,               "AFTER_SEQ format: $fAfterSeq");
// include_year=0: PFX-####
$db->insertUpdate("UPDATE doc_number_settings SET include_year=0 WHERE doc_key='RECEIPT'", []);
$fNoYear = $dns->peek('RECEIPT');
ok(preg_match('/^TST-0042$/', $fNoYear) === 1,                       "no-year format: $fNoYear");

/* ── 7. Guards ─────────────────────────────────────────────────────── */
h('7. Guards');
$threw = false; try { $dns->allocate('DOES_NOT_EXIST'); } catch (Throwable $e) { $threw = stripos($e->getMessage(), 'Unknown doc_key') !== false; }
ok($threw,                                                         'allocate throws on unknown doc_key');
$rec = $dns->findByKey('RECEIPT');
$badWidth = $dns->configure((int)$rec['c0d'], ['seq_width' => 20], 1);
ok(!$badWidth['ok'] && stripos($badWidth['msg'], '1..12') !== false, 'seq_width > 12 refused');
$badStart = $dns->configure((int)$rec['c0d'], ['start_no' => 0],  1);
ok(!$badStart['ok'] && stripos($badStart['msg'], 'start_no') !== false, 'start_no < 1 refused');
$badNext  = $dns->configure((int)$rec['c0d'], ['next_no' => 1, 'start_no' => 500], 1);
ok(!$badNext['ok']  && stripos($badNext['msg'],  'next_no cannot be less') !== false, 'next_no < start_no refused');

/* ── 8. configure() persists ───────────────────────────────────────── */
h('8. configure() persists');
$upd = $dns->configure((int)$rec['c0d'], [
    'label' => 'Test Receipt Cfg', 'prefix' => 'RCP', 'separator' => '-',
    'seq_width' => 6, 'start_no' => 100, 'next_no' => 200,
    'include_year' => 1, 'year_position' => 'AFTER_PREFIX', 'reset_scope' => 'YEARLY',
    'notes' => 'edited by verify',
], 1);
ok($upd['ok'], 'configure accepted');
$recAft = $dns->findByKey('RECEIPT');
ok((string)$recAft['label'] === 'Test Receipt Cfg',                'label persisted');
ok((int)$recAft['seq_width'] === 6,                                 'seq_width persisted');
ok((int)$recAft['start_no']  === 100,                                'start_no persisted');
ok((int)$recAft['next_no']   === 200,                                'next_no persisted');

/* ── 9. Wired callers use DocNumberSettings ────────────────────────── */
h('9. Wired callers pull from DocNumberSettings');
// Change the LPO prefix to something unmistakable + set next_no to 500000
$lpoRow = $dns->findByKey('LPO');
$db->insertUpdate("UPDATE doc_number_settings SET prefix='ZLPO', next_no=500000, include_year=1, year_position='AFTER_PREFIX', reset_scope='NEVER' WHERE c0d = :id", [':id' => (int)$lpoRow['c0d']]);
$fromLpos = (new Lpos())->nextLpoNo('SUPPLIER');
ok(preg_match('/^ZLPO-\d{4}-500000$/', $fromLpos) === 1,             "Lpos::nextLpoNo('SUPPLIER') pulls from DocNumberSettings: $fromLpos");

// PaymentVouchers
$pvRow = $dns->findByKey('PV');
$db->insertUpdate("UPDATE doc_number_settings SET prefix='ZPV', next_no=88888, reset_scope='NEVER' WHERE c0d = :id", [':id' => (int)$pvRow['c0d']]);
$fromPv = (new PaymentVouchers())->nextPvNo();
ok(preg_match('/^ZPV-\d{4}-88888$/', $fromPv) === 1,                 "PaymentVouchers::nextPvNo pulls from DocNumberSettings: $fromPv");

$payRow = $dns->findByKey('PAYMENT');
$db->insertUpdate("UPDATE doc_number_settings SET prefix='ZPAY', next_no=77777, reset_scope='NEVER' WHERE c0d = :id", [':id' => (int)$payRow['c0d']]);
$fromPay = (new PaymentVouchers())->nextPaymentNo();
ok(preg_match('/^ZPAY-\d{4}-77777$/', $fromPay) === 1,               "PaymentVouchers::nextPaymentNo pulls from DocNumberSettings: $fromPay");

// Receipts
$recRow2 = $dns->findByKey('RECEIPT');
$db->insertUpdate("UPDATE doc_number_settings SET prefix='ZRCP', next_no=66666, seq_width=4, include_year=1, year_position='AFTER_PREFIX', reset_scope='NEVER' WHERE c0d = :id", [':id' => (int)$recRow2['c0d']]);
$fromRcp = (new Receipts())->nextReceiptNo();
ok(preg_match('/^ZRCP-\d{4}-66666$/', $fromRcp) === 1,               "Receipts::nextReceiptNo pulls from DocNumberSettings: $fromRcp");

// Sales
$saleRow = $dns->findByKey('SALE');
$db->insertUpdate("UPDATE doc_number_settings SET prefix='ZINV', next_no=55555, reset_scope='NEVER' WHERE c0d = :id", [':id' => (int)$saleRow['c0d']]);
$fromSale = (new Sales())->nextSaleNo();
ok(preg_match('/^ZINV-\d{4}-55555$/', $fromSale) === 1,              "Sales::nextSaleNo pulls from DocNumberSettings: $fromSale");

// WorksCrs, GoodsScrubPack, GoodsOnboarding — source assertions (they call from within their own methods)
$mBodyCr    = (string)file_get_contents(ROOT . DS . 'app' . DS . 'models' . DS . 'WorksCrs.php');
ok(strpos($mBodyCr, "DocNumberSettings())->allocate('CR')")     !== false, 'WorksCrs::draft calls DocNumberSettings->allocate(CR)');
$mBodyScr   = (string)file_get_contents(ROOT . DS . 'app' . DS . 'models' . DS . 'GoodsScrubPack.php');
ok(strpos($mBodyScr, "DocNumberSettings())->allocate('SCRUB')") !== false, 'GoodsScrubPack calls DocNumberSettings->allocate(SCRUB)');
$mBodyOnb   = (string)file_get_contents(ROOT . DS . 'app' . DS . 'models' . DS . 'GoodsOnboarding.php');
ok(strpos($mBodyOnb, "DocNumberSettings())->allocate('CLIENT_PO')") !== false, 'GoodsOnboarding calls DocNumberSettings->allocate(CLIENT_PO)');

// Admin surface + sidebar
ok(is_file(ROOT . DS . 'app' . DS . 'views' . DS . 'DocNumbers' . DS . 'index.php'), 'DocNumbers/index.php view exists');
$sidebar = (string)file_get_contents(ROOT . DS . 'app' . DS . 'views' . DS . 'layouts' . DS . 'partials' . DS . '_sidebar_administrator.php');
ok(strpos($sidebar, "PROOT; ?>DocNumbers") !== false, 'admin sidebar links to DocNumbers');
$adminRow = $db->mySelect("SELECT 1 FROM user_groups g JOIN user_groups_perms p ON p.user_group_id=g.c0d WHERE g.user_group_name='Administrator' AND p.controller_allowed='DocNumbers' AND p.action_allowed='saveAj' LIMIT 1", []);
ok(!empty($adminRow), 'Admin has DocNumbers/saveAj perm');

/* ── Z. Restore snapshot ────────────────────────────────────────────── */
h('Z. Restore seed state so downstream verifies see original config');
foreach ($snap as $key => $row) {
    $db->insertUpdate(
        "UPDATE doc_number_settings SET `label`=:lbl, `prefix`=:pfx, `separator`=:sep, `seq_width`=:sw,
                                        `start_no`=:st, `next_no`=:nx, `include_year`=:iy, `year_position`=:yp,
                                        `reset_scope`=:rs, `current_year`=:cy, `current_month`=:cm, `notes`=:nt
          WHERE `doc_key` = :k",
        [
            ':lbl' => $row['label'], ':pfx' => $row['prefix'], ':sep' => $row['separator'], ':sw' => (int)$row['seq_width'],
            ':st'  => (int)$row['start_no'], ':nx' => (int)$row['next_no'], ':iy' => (int)$row['include_year'],
            ':yp'  => $row['year_position'], ':rs' => $row['reset_scope'],
            ':cy'  => (int)$row['current_year'], ':cm' => (int)$row['current_month'],
            ':nt'  => $row['notes'], ':k' => $key,
        ]
    );
}
p('restored ' . count($snap) . ' doc_key rows to pre-verify state');

echo "\n=== END ===\n";
