<?php
/**
 * Slice 8L-TenderItems · buyer-facing item list per tender.
 *
 *  1) Schema present + default_kind mapping by project_type
 *  2) add() rejects empty description
 *  3) add() creates row with correct line_no + default kind
 *  4) listFor() returns rows in line_no order
 *  5) nextLineNo() is monotonic
 *  6) update() enforces non-empty description + valid kind
 *  7) remove() soft-deletes (deleted=1)
 *  8) reorder() writes new line_no values in the given order
 *  9) bulkImport() adds N rows, counts errors
 * 10) Perms: CRM has full write, PC/PM/FM/Exec have read-only
 * 11) Controller endpoints registered (grep view.php surface)
 * 12) View surface: Items tab in view.php, Vue app root + KIND constant
 */
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 (['Tenders.php','TenderItems.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();
$ti = new TenderItems();
$tm = new Tenders();
$crmId = 111; $crmNm = 'Verify CRM';

/* ── 0. Anchor: pick a live tender, purge prior 8L items on it ─────── */
h('0. Anchor tender + purge prior 8L items');
$anchor = $db->mySelect("SELECT c0d, project_type, tender_title FROM tender_tracker WHERE deleted=0 ORDER BY c0d ASC LIMIT 1", []);
if (empty($anchor)) boom('no tenders in DB — cannot verify Slice 8L against a real row');
$tenderId   = (int)$anchor[0]['c0d'];
$projectType= (string)$anchor[0]['project_type'];
p("anchor tender #{$tenderId} · type={$projectType}");

$db->insertUpdate("DELETE FROM tender_items WHERE tender_id = :t AND description LIKE '8L-VERIFY%'", [':t' => $tenderId]);

/* ── 1. Schema + default_kind mapping ──────────────────────────────── */
h('1. Schema + kind mapping');
$cols = $db->mySelect("SHOW COLUMNS FROM tender_items", []) ?: [];
$colNames = array_column($cols, 'Field');
foreach (['tender_id','line_no','item_ref','description','qty','uom','spec_notes','line_kind','deleted'] as $c) {
    ok(in_array($c, $colNames, true), "column present: $c");
}
ok(TenderItems::defaultKindFor('goods')       === 'GOOD',      'goods       → GOOD');
ok(TenderItems::defaultKindFor('services')    === 'SERVICE',   'services    → SERVICE');
ok(TenderItems::defaultKindFor('works')       === 'WORKS_BOQ', 'works       → WORKS_BOQ');
ok(TenderItems::defaultKindFor('consultancy') === 'SERVICE',   'consultancy → SERVICE');
ok(TenderItems::defaultKindFor('anything')    === 'OTHER',     'unknown     → OTHER');

/* ── 2. add() rejects empty description ────────────────────────────── */
h('2. add() rejects empty description');
$r0 = $ti->add($tenderId, ['description' => '   ', 'qty' => 1], $crmId, $crmNm);
ok(!$r0['ok'] && stripos($r0['msg'], 'required') !== false, 'blank description refused');

/* ── 3. add() creates row + nextLineNo monotonic ───────────────────── */
h('3. add() creates row · nextLineNo monotonic');
$baseNext = $ti->nextLineNo($tenderId);
$r1 = $ti->add($tenderId, ['description' => '8L-VERIFY line A', 'qty' => 3, 'uom' => 'ea', 'item_ref' => 'A-01'], $crmId, $crmNm);
ok($r1['ok'] && $r1['id'] > 0, 'first row created');
$id1 = (int)$r1['id'];
$row1 = $ti->find($id1);
ok((int)$row1['line_no'] === $baseNext, "line_no = $baseNext (as peeked)");
$defKind = TenderItems::defaultKindFor($projectType);
ok($row1['line_kind'] === $defKind, "default kind for {$projectType} sticks: $defKind");
$r2 = $ti->add($tenderId, ['description' => '8L-VERIFY line B', 'qty' => 5, 'uom' => 'm', 'line_kind' => 'LOGISTICS'], $crmId, $crmNm);
$id2 = (int)$r2['id']; $row2 = $ti->find($id2);
ok((int)$row2['line_no'] === $baseNext + 1, 'second row line_no = baseNext + 1');
ok($row2['line_kind'] === 'LOGISTICS', 'explicit kind honoured');

/* ── 4. listFor() ordered by line_no ───────────────────────────────── */
h('4. listFor() ordered by line_no');
$all = $ti->listFor($tenderId);
$mine = array_values(array_filter($all, fn($r) => strpos($r['description'], '8L-VERIFY') === 0));
ok(count($mine) === 2, 'listFor returns 2 verify rows');
ok((int)$mine[0]['line_no'] < (int)$mine[1]['line_no'], 'sorted ascending by line_no');

/* ── 5. update() enforces desc + kind ──────────────────────────────── */
h('5. update() enforces desc + valid kind');
$rU0 = $ti->update($id1, ['description' => ''], $crmId);
ok(!$rU0['ok'] && stripos($rU0['msg'], 'required') !== false, 'blank desc rejected on update');
$rU1 = $ti->update($id1, ['description' => '8L-VERIFY line A (edited)', 'qty' => 4, 'line_kind' => 'GARBAGE'], $crmId);
ok($rU1['ok'], 'valid update accepted');
$row1u = $ti->find($id1);
ok((float)$row1u['qty'] === 4.0, 'qty updated');
ok($row1u['line_kind'] === $defKind, 'invalid kind fell back to previous value, not "GARBAGE"');

/* ── 6. remove() soft deletes ──────────────────────────────────────── */
h('6. remove() soft-deletes');
$rR = $ti->remove($id2, $crmId);
ok($rR['ok'], 'remove ok');
$stillFind = $ti->find($id2);
ok($stillFind === null, 'find() no longer returns removed row');
$rawRow = $db->mySelect("SELECT deleted FROM tender_items WHERE c0d = :id", [':id' => $id2]);
ok((int)$rawRow[0]['deleted'] === 1, 'raw row has deleted=1 (soft delete)');

/* ── 7. reorder() rewrites line_no in given order ──────────────────── */
h('7. reorder() rewrites line_no');
$idA = (int)$ti->add($tenderId, ['description' => '8L-VERIFY reorder A'], $crmId, $crmNm)['id'];
$idB = (int)$ti->add($tenderId, ['description' => '8L-VERIFY reorder B'], $crmId, $crmNm)['id'];
$idC = (int)$ti->add($tenderId, ['description' => '8L-VERIFY reorder C'], $crmId, $crmNm)['id'];
$rOrd = $ti->reorder($tenderId, [$idC, $idA, $idB]);
ok($rOrd['ok'] && $rOrd['count'] === 3, 'reorder wrote 3 rows');
$cLn = (int)$ti->find($idC)['line_no']; $aLn = (int)$ti->find($idA)['line_no']; $bLn = (int)$ti->find($idB)['line_no'];
ok($cLn === 1 && $aLn === 2 && $bLn === 3, "C=1, A=2, B=3 · got $cLn/$aLn/$bLn");

/* ── 8. bulkImport() adds N rows ───────────────────────────────────── */
h('8. bulkImport()');
$bulkRows = [
    ['description' => '8L-VERIFY bulk 1', 'qty' => 10, 'uom' => 'ea'],
    ['description' => '8L-VERIFY bulk 2', 'qty' => 20, 'uom' => 'm'],
    ['description' => '',                 'qty' => 1],  // should fail
];
$rB = $ti->bulkImport($tenderId, $bulkRows, $crmId, $crmNm);
ok($rB['added'] === 2, "2 valid rows imported (got {$rB['added']})");
ok(count($rB['errors']) === 1, 'exactly 1 error reported for blank desc row');

/* ── 9. Perms surface ──────────────────────────────────────────────── */
h('9. Perms surface');
$crmWrites = $db->mySelect(
    "SELECT p.action_allowed FROM user_groups_perms p
       JOIN user_groups g ON g.c0d = p.user_group_id
      WHERE g.user_group_name = 'CRM' AND p.controller_allowed = 'Tenders' AND p.action_allowed LIKE 'items%'",
    []) ?: [];
$crmActs = array_column($crmWrites, 'action_allowed');
foreach (['itemsListAj','itemsAddAj','itemsUpdateAj','itemsRemoveAj','itemsReorderAj','itemsBulkImportAj'] as $a) {
    ok(in_array($a, $crmActs, true), "CRM has $a");
}
$pcReads = $db->mySelect(
    "SELECT p.action_allowed FROM user_groups_perms p
       JOIN user_groups g ON g.c0d = p.user_group_id
      WHERE g.user_group_name = 'Projects Coordinator' AND p.controller_allowed = 'Tenders' AND p.action_allowed LIKE 'items%'",
    []) ?: [];
$pcActs = array_column($pcReads, 'action_allowed');
ok(in_array('itemsListAj', $pcActs, true), 'PC has itemsListAj (read)');
ok(!in_array('itemsAddAj', $pcActs, true), 'PC does NOT have itemsAddAj (read-only)');

/* ── 10. Controller endpoints wired ────────────────────────────────── */
h('10. Controller endpoints');
$cBody = (string)file_get_contents(ROOT . DS . 'app' . DS . 'controllers' . DS . 'TendersController.php');
foreach (['itemsListAjAction','itemsAddAjAction','itemsUpdateAjAction','itemsRemoveAjAction','itemsReorderAjAction','itemsBulkImportAjAction'] as $a) {
    ok(strpos($cBody, "function $a") !== false, "TendersController::$a defined");
}

/* ── 11. View surface: Items tab + Vue root ────────────────────────── */
h('11. View surface');
$vBody = (string)file_get_contents(ROOT . DS . 'app' . DS . 'views' . DS . 'Tenders' . DS . 'view.php');
ok(strpos($vBody, '#tab-items') !== false,       'Items tab link present');
ok(strpos($vBody, 'id="tab-items"') !== false,   'Items tab body present');
ok(strpos($vBody, 'ti_app_root') !== false,      'Vue root element present');
ok(strpos($vBody, 'Vue.createApp')  !== false,   'Vue.createApp call present');
ok(strpos($vBody, 'vue.global') !== false,       'Vue 3 CDN loaded');
ok(strpos($vBody, 'itemsAddAj') !== false,       'view posts to itemsAddAj');
ok(strpos($vBody, 'itemsUpdateAj') !== false,    'view posts to itemsUpdateAj');
ok(strpos($vBody, 'itemsRemoveAj') !== false,    'view posts to itemsRemoveAj');
ok(strpos($vBody, 'itemsBulkImportAj') !== false,'view posts to itemsBulkImportAj');

/* ── Z. Purge verify artefacts (leave real tender rows intact) ─────── */
h('Z. Restore');
$db->insertUpdate("DELETE FROM tender_items WHERE tender_id = :t AND description LIKE '8L-VERIFY%'", [':t' => $tenderId]);
p('purged verify items · anchor unchanged');

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