<?php
class TendersController extends Controller
{
    public function indexAction()
    {
        $pageSize = 25;
        $page     = max(1, (int)($_GET['page'] ?? 1));
        $filter = [
            'stage'        => (string)($_GET['stage']  ?? ''),
            'status'       => (string)($_GET['status'] ?? ''),
            'source'       => (string)($_GET['source'] ?? ''),
            'project_type' => (string)($_GET['type']   ?? ''),
            'q'            => trim((string)($_GET['q'] ?? '')),
        ];
        $m = new Tenders();
        $total = $m->countAll($filter);

        // Snap page into range if user requested beyond the last page (after filter change).
        $lastPage = max(1, (int)ceil($total / $pageSize));
        if ($page > $lastPage) $page = $lastPage;

        $rows = $m->listAll($filter + ['page' => $page, 'page_size' => $pageSize]);

        $this->view->setLayout($this->roleLayout());
        $this->view->render('Tenders/index', [
            'page_title' => 'Tenders',
            'rows'       => $rows,
            'filter'     => $filter,
            'page'       => $page,
            'page_size'  => $pageSize,
            'total'      => $total,
            'last_page'  => $lastPage,
        ]);
    }

    public function addAction()   { $this->handleForm(null); }
    public function editAction($id = 0) { $this->handleForm((int)$id); }

    private function handleForm(?int $id): void
    {
        $m = new Tenders();
        $userId   = (int)($_SESSION['user_id'] ?? 0);
        $userName = (string)($_SESSION['full_name'] ?? '');

        if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
            if ($id === null) { $id = $m->create($_POST, $userId, $userName); }
            else              { $m->update($id, $_POST, $userId); }
            header('Location: ' . PROOT . 'Tenders/view/' . $id);
            return;
        }

        $existing = $id ? $m->find($id) : null;

        // Prefill from NEST convert (source_type=NEST_PUBLIC + query-string mapped fields)
        $prefill = [
            'source_type'       => (string)($_GET['source_type']    ?? ($existing['source_type']    ?? 'PRIVATE_CLIENT')),
            'source_ref'        => (string)($_GET['source_ref']     ?? ($existing['source_ref']     ?? '')),
            'source_url'        => (string)($_GET['source_url']     ?? ($existing['source_url']     ?? '')),
            'tender_title'      => (string)($_GET['tender_title']   ?? ($existing['tender_title']   ?? '')),
            'buyer_name'        => (string)($_GET['buyer_name']     ?? ($existing['buyer_name']     ?? '')),
            'tender_description'=> (string)($_GET['description']    ?? ($existing['tender_description'] ?? '')),
            'estimated_value'   => (float) ($_GET['estimated_value']?? ($existing['estimated_value']?? 0)),
            'region'            => (string)($_GET['region']         ?? ($existing['region']         ?? '')),
            'project_type'      => (string)($_GET['project_type']   ?? ($existing['project_type']   ?? 'goods')),
            'deadline_date'     => (string)($_GET['deadline_date']  ?? ($existing['deadline_date']  ?? '')),
        ];

        // Customer dropdown (for private tenders)
        $db = new DB();
        $customers = $db->mySelect("SELECT c0d, fname, lname, customer_no FROM customers WHERE deleted = 0 ORDER BY fname, lname LIMIT 500", []) ?: [];

        $this->view->setLayout($this->roleLayout());
        $this->view->render('Tenders/form', [
            'page_title' => $id ? 'Edit Tender' : 'Add Tender',
            'tender'     => $existing,
            'prefill'    => $prefill,
            'customers'  => $customers,
            'sources'    => Tenders::SOURCES,
            'stages'     => Tenders::STAGES,
        ]);
    }

    public function viewAction($id = 0)
    {
        $id = (int)$id;
        $m = new Tenders();
        $t = $m->find($id);
        if (!$t) { header('HTTP/1.1 404 Not Found'); echo 'Tender not found'; return; }
        $this->view->setLayout($this->roleLayout());
        // Pricing data (Slice 4)
        $tp = new TenderPricing();
        $pricingVersions = $tp->versionsFor($id);
        $pricingCurrent  = null;
        $pricingSections = [];
        $pricingLinesBySection = [];
        $pricingOverheads = [];
        if (!empty($pricingVersions)) {
            // Prefer the DRAFT if one exists, else the newest APPROVED, else the newest
            $pricingCurrent = $tp->activeDraft($id) ?: ($tp->latestApproved($id) ?: $pricingVersions[0]);
            $pricingSections = $tp->sectionsFor((int)$pricingCurrent['c0d']);
            foreach ($pricingSections as $s) $pricingLinesBySection[(int)$s['c0d']] = $tp->linesFor((int)$s['c0d']);
            // Slice 8S: overhead pool for the current version
            if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
            $pricingOverheads = (new PricingVersionOverheads())->listFor((int)$pricingCurrent['c0d']);
        }

        $this->view->render('Tenders/view', [
            'page_title'              => 'Tender · ' . htmlspecialchars($t['tender_no']),
            'tender'                  => $t,
            'stage_history'           => $m->stageHistory($id),
            'bidders'                 => $m->bidders($id),
            'result'                  => $m->result($id),
            'stages'                  => Tenders::STAGES,
            'doc_types'               => $m->docTypesGrouped(),
            'required_ids'            => $m->requiredDocTypeIds($id),
            'contract_terms'          => $m->contractTerms($id),
            'contract_approval'       => $m->contractApproval($id),
            'contract_history'        => $m->contractApprovalHistory($id),
            'contract_signing'        => $m->contractSigning($id),
            'contract_receipts'       => $m->contractReceipts($id),
            'contract_terms_audit'    => $m->contractTermsAudit($id),
            'can_edit_locked_terms'   => Tenders::canEditLockedTerms((string)($_SESSION['user_group_name'] ?? '')),
            'pricing_versions'        => $pricingVersions,
            'pricing_current'         => $pricingCurrent,
            'pricing_sections'        => $pricingSections,
            'pricing_lines_by_section'=> $pricingLinesBySection,
            'pricing_overheads'       => $pricingOverheads,
            // Slice 8L: buyer-facing item list (Vue root hydrates from these)
            'tender_items'            => (new TenderItems())->listFor($id),
            'tender_items_default_kind' => TenderItems::defaultKindFor((string)$t['project_type']),
            // Slice 8T: proof-of-submission attachments
            'submission_attachments'  => (function () use ($id) {
                if (!class_exists('TenderSubmissionAttachments')) require_once ROOT . '/app/models/TenderSubmissionAttachments.php';
                return (new TenderSubmissionAttachments())->listFor($id);
            })(),
            // Slice 8S-10 · Services bid-time answers (empty for goods/works — view guards on project_type)
            'service_spec' => (function () use ($id, $t) {
                if (($t['project_type'] ?? '') !== 'services') return [];
                if (!class_exists('TenderServiceSpec')) require_once ROOT . '/app/models/TenderServiceSpec.php';
                return (new TenderServiceSpec())->forTender($id);
            })(),
            'service_spec_equipment' => (function () use ($id, $t) {
                if (($t['project_type'] ?? '') !== 'services') return [];
                if (!class_exists('TenderServiceSpec')) require_once ROOT . '/app/models/TenderServiceSpec.php';
                $tss = new TenderServiceSpec();
                return $tss->decodedEquipment($tss->forTender($id));
            })(),
            'service_spec_can_edit' => in_array((string)($_SESSION['user_group_name'] ?? ''), ['Administrator', 'CRM'], true),
            // Slice 8Z-q · Contract bonds tracker + FM verification attachments
            'contract_bonds' => (function () use ($id) {
                if (!class_exists('TenderContractBonds')) require_once ROOT . '/app/models/TenderContractBonds.php';
                $m = new TenderContractBonds();
                return $m->withExpiry($m->listFor($id));
            })(),
            'fm_verify_attachments' => (function () use ($id) {
                if (!class_exists('TenderFmVerificationAttachments')) require_once ROOT . '/app/models/TenderFmVerificationAttachments.php';
                return (new TenderFmVerificationAttachments())->listFor($id);
            })(),
            'fm_verify_mandatory' => (function () use ($id) {
                if (!class_exists('TenderFmVerificationAttachments')) require_once ROOT . '/app/models/TenderFmVerificationAttachments.php';
                return (new TenderFmVerificationAttachments())->mandatoryCheck($id);
            })(),
            // Slice 8Z-s · Kickoff brief bundle (FM + Exec halves)
            'kickoff_brief' => (function () use ($id) {
                if (!class_exists('TenderExecKickoffBrief')) require_once ROOT . '/app/models/TenderExecKickoffBrief.php';
                return (new TenderExecKickoffBrief())->forTender($id);
            })(),
            'kickoff_budget_ceiling' => (function () use ($id) {
                if (!class_exists('TenderExecKickoffBrief')) require_once ROOT . '/app/models/TenderExecKickoffBrief.php';
                return (new TenderExecKickoffBrief())->budgetCeiling($id);
            })(),
            'kickoff_editable_sections' => (function () {
                if (!class_exists('TenderExecKickoffBrief')) require_once ROOT . '/app/models/TenderExecKickoffBrief.php';
                return (new TenderExecKickoffBrief())->editableSectionsFor((string)($_SESSION['user_group_name'] ?? ''));
            })(),
        ]);
    }

    /* ── Slice 8S-10 · Tender Service Spec (Services tenders only) ────── */

    /**
     * Slice 8Z-s · Upsert the Executive/FM kickoff brief. RBAC on which sections the
     * caller's role may write is enforced inside TenderExecKickoffBrief::save() —
     * fields outside their remit are silently dropped so a single endpoint serves both
     * roles without conflicts.
     */
    public function saveKickoffBriefAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing tender_id.');
            if (!class_exists('TenderExecKickoffBrief')) require_once ROOT . '/app/models/TenderExecKickoffBrief.php';
            $res = (new TenderExecKickoffBrief())->save(
                $tid, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name']       ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Slice 8Z-r · Return the current contractCompletenessMissing() list so the
     * "Before handing off, fill" checklist + Verify button can update in-place
     * without a page reload. Called after every section save + bond/FM mutation.
     */
    public function contractCompletenessAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? $_GET['tender_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing tender_id.');
            $tm        = new Tenders();
            $missing   = $tm->contractCompletenessMissing($tid);
            $checklist = $tm->contractCompletenessChecklist($tid);
            $sections  = $tm->contractSectionCompleteness($tid);
            echo json_encode([
                'ok'        => true,
                'missing'   => $missing,
                'checklist' => $checklist,
                'sections'  => $sections,
                'count'     => count($missing),
                'all_ok'    => empty($missing),
            ]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Slice 8Z-q · Contract bonds & FM verification attachments ────── */

    /** List every non-deleted bond/receipt for a tender, decorated with expiry chip. */
    public function bondsListAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? $_GET['tender_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing tender_id.');
            if (!class_exists('TenderContractBonds')) require_once ROOT . '/app/models/TenderContractBonds.php';
            $m = new TenderContractBonds();
            $rows = $m->withExpiry($m->listFor($tid));
            echo json_encode(['ok' => true, 'rows' => $rows]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Upsert a bond row (no file — that's bondDocUploadAj). */
    public function bondSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing tender_id.');
            if (!class_exists('TenderContractBonds')) require_once ROOT . '/app/models/TenderContractBonds.php';
            $res = (new TenderContractBonds())->save($tid, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Attach / replace the supporting document on a bond row. */
    public function bondDocUploadAjAction()
    {
        header('Content-Type: application/json');
        try {
            $bid = (int)($_POST['bond_id'] ?? 0);
            if ($bid <= 0) throw new InvalidArgumentException('Missing bond_id.');
            if (empty($_FILES['bond_file'])) throw new InvalidArgumentException('No file received.');
            if (!class_exists('TenderContractBonds')) require_once ROOT . '/app/models/TenderContractBonds.php';
            $res = (new TenderContractBonds())->uploadDoc($bid, $_FILES['bond_file'], (int)($_SESSION['user_id'] ?? 0));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function bondRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $bid = (int)($_POST['bond_id'] ?? 0);
            if ($bid <= 0) throw new InvalidArgumentException('Missing bond_id.');
            if (!class_exists('TenderContractBonds')) require_once ROOT . '/app/models/TenderContractBonds.php';
            echo json_encode((new TenderContractBonds())->remove($bid, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function bondDocDownloadAction($bondId = 0)     { $this->serveBondDoc((int)$bondId, false); }
    public function bondDocPreviewAction($bondId = 0)      { $this->serveBondDoc((int)$bondId, true);  }

    private function serveBondDoc(int $bondId, bool $inline): void
    {
        if (!class_exists('TenderContractBonds')) require_once ROOT . '/app/models/TenderContractBonds.php';
        $m = new TenderContractBonds();
        $row = $m->find($bondId);
        if (!$row || empty($row['document_path'])) { header('HTTP/1.1 404 Not Found'); echo 'Bond document not found'; return; }
        $abs = $m->absoluteDocPath($row);
        if (!$abs) { header('HTTP/1.1 410 Gone'); echo 'File missing on disk'; return; }
        header('Content-Type: ' . ($row['document_mime'] ?: 'application/octet-stream'));
        header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . addslashes((string)$row['document_original_filename']) . '"');
        header('Content-Length: ' . filesize($abs));
        header('Cache-Control: private, no-cache');
        readfile($abs);
    }

    /* ── FM verification attachments (Batch F) ───────────────────────── */

    public function fmAttachListAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? $_GET['tender_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing tender_id.');
            if (!class_exists('TenderFmVerificationAttachments')) require_once ROOT . '/app/models/TenderFmVerificationAttachments.php';
            $m = new TenderFmVerificationAttachments();
            echo json_encode([
                'ok'         => true,
                'rows'       => $m->listFor($tid),
                'mandatory'  => $m->mandatoryCheck($tid),
            ]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function fmAttachUploadAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing tender_id.');
            if (empty($_FILES['fm_file'])) throw new InvalidArgumentException('No file received.');
            if (!class_exists('TenderFmVerificationAttachments')) require_once ROOT . '/app/models/TenderFmVerificationAttachments.php';
            $res = (new TenderFmVerificationAttachments())->upload($tid, $_FILES['fm_file'], [
                'subtype' => (string)($_POST['subtype'] ?? 'OTHER'),
                'label'   => (string)($_POST['label']   ?? ''),
                'notes'   => (string)($_POST['notes']   ?? ''),
            ], (int)($_SESSION['user_id'] ?? 0), (string)($_SESSION['full_name'] ?? ''));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function fmAttachRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $aid = (int)($_POST['attachment_id'] ?? 0);
            if ($aid <= 0) throw new InvalidArgumentException('Missing attachment_id.');
            if (!class_exists('TenderFmVerificationAttachments')) require_once ROOT . '/app/models/TenderFmVerificationAttachments.php';
            echo json_encode((new TenderFmVerificationAttachments())->remove($aid, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function fmAttachDownloadAction($attachmentId = 0) { $this->serveFmAttach((int)$attachmentId, false); }
    public function fmAttachPreviewAction($attachmentId = 0)  { $this->serveFmAttach((int)$attachmentId, true);  }

    private function serveFmAttach(int $attachmentId, bool $inline): void
    {
        if (!class_exists('TenderFmVerificationAttachments')) require_once ROOT . '/app/models/TenderFmVerificationAttachments.php';
        $m = new TenderFmVerificationAttachments();
        $row = $m->find($attachmentId);
        if (!$row) { header('HTTP/1.1 404 Not Found'); echo 'Attachment not found'; return; }
        $abs = $m->absolutePath($row);
        if (!$abs) { header('HTTP/1.1 410 Gone'); echo 'File missing on disk'; return; }
        header('Content-Type: ' . ($row['mime_type'] ?: 'application/octet-stream'));
        header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . addslashes((string)$row['original_filename']) . '"');
        header('Content-Length: ' . filesize($abs));
        header('Cache-Control: private, no-cache');
        readfile($abs);
    }

    /**
     * Slice 8S-10 · Stream the Services Walkthrough Manual PDF for a tender.
     * Services tenders only — Goods/Works/Consultancy return 404.
     */
    public function walkthroughManualPdfAction($id = 0)
    {
        $tid = (int)$id;
        $t = (new Tenders())->find($tid);
        if (!$t || ($t['project_type'] ?? '') !== 'services') {
            header('HTTP/1.1 404 Not Found'); echo 'Services tender not found.'; return;
        }
        foreach (['TenderItems','TenderPricing','TenderServiceSpec','PricingVersionOverheads','ServicesWalkthroughManual'] as $m) {
            if (!class_exists($m)) require_once ROOT . '/app/models/' . $m . '.php';
        }
        $tss   = new TenderServiceSpec();
        $spec  = $tss->forTender($tid);
        $equip = $tss->decodedEquipment($spec);
        $items = (new TenderItems())->listFor($tid);
        $tp    = new TenderPricing();
        $db    = new DB();
        $vers  = $db->mySelect("SELECT * FROM tender_pricing_versions WHERE tender_id = :t ORDER BY c0d DESC LIMIT 1", [':t' => $tid]);
        $version = !empty($vers) ? $vers[0] : null;
        $sections = []; $lines = []; $overheads = [];
        if ($version) {
            $sections = $tp->sectionsFor((int)$version['c0d']);
            foreach ($sections as $s) $lines[(int)$s['c0d']] = $tp->linesFor((int)$s['c0d']);
            $overheads = (new PricingVersionOverheads())->listFor((int)$version['c0d']);
        }
        $pdf = ServicesWalkthroughManual::build([
            'tender'    => $t,       'spec'      => $spec,   'equipment' => $equip,
            'items'     => $items,   'version'   => $version,
            'sections'  => $sections,'lines'     => $lines,  'overheads' => $overheads,
        ]);
        $inline  = strtolower((string)($_GET['disposition'] ?? '')) !== 'attachment';
        $fname   = 'services_walkthrough_' . preg_replace('/[^A-Za-z0-9._-]/', '_', (string)$t['tender_no']) . '.pdf';
        header('Content-Type: application/pdf');
        header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . $fname . '"');
        header('Content-Length: ' . strlen($pdf));
        header('Cache-Control: private, no-cache');
        echo $pdf;
    }

    /** Upsert the "as bid" answers to the 10-question CRM checklist for a Services tender. */
    public function saveServiceSpecAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing tender_id.');
            $t = (new Tenders())->find($tid);
            if (!$t)                                            throw new RuntimeException('Tender not found.');
            if (($t['project_type'] ?? '') !== 'services')      throw new RuntimeException('Service spec applies to Services tenders only.');
            $grp = (string)($_SESSION['user_group_name'] ?? '');
            if (!in_array($grp, ['Administrator', 'CRM'], true)) throw new RuntimeException('Only Admin or CRM can edit the service spec.');
            if (!class_exists('TenderServiceSpec')) require_once ROOT . '/app/models/TenderServiceSpec.php';
            $id = (new TenderServiceSpec())->save($tid, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true, 'spec_id' => $id]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Requirements AJAX ─────────────────────────────────────────────── */

    public function setRequirementsAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id  = (int)($_POST['tender_id'] ?? 0);
            $ids = isset($_POST['doc_type_ids']) && is_array($_POST['doc_type_ids']) ? $_POST['doc_type_ids'] : [];
            $n = (new Tenders())->setRequirements($id, $ids, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true, 'saved' => $n]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function applyStatutoryDefaultsAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            $n = (new Tenders())->applyStatutoryDefaults($id, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true, 'added' => $n]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function clearRequirementsAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            (new Tenders())->clearRequirements($id);
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Submission tab ────────────────────────────────────────────────── */

    /* Slice 8P · Save Details tab fields inline (CRM + Admin + Exec) */
    public function saveDetailsAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            if ($id <= 0) throw new InvalidArgumentException('Missing tender_id.');
            (new Tenders())->saveDetails($id, $_POST, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function saveSubmissionAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            if ($id <= 0) throw new InvalidArgumentException('Missing tender_id.');
            (new Tenders())->saveSubmissionDetails($id, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Slice 8T · Submission-proof attachments ───────────────────── */

    public function submissionAttachmentUploadAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('TenderSubmissionAttachments')) require_once ROOT . '/app/models/TenderSubmissionAttachments.php';
            $tid = (int)($_POST['tender_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing tender_id.');
            if (empty($_FILES['proof_file'])) throw new InvalidArgumentException('No file received.');
            $res = (new TenderSubmissionAttachments())->upload($tid, $_FILES['proof_file'], [
                'kind'  => (string)($_POST['kind']  ?? 'OTHER'),
                'label' => (string)($_POST['label'] ?? ''),
                'notes' => (string)($_POST['notes'] ?? ''),
            ], (int)($_SESSION['user_id'] ?? 0), (string)($_SESSION['full_name'] ?? ''));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function submissionAttachmentRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('TenderSubmissionAttachments')) require_once ROOT . '/app/models/TenderSubmissionAttachments.php';
            $id  = (int)($_POST['attachment_id'] ?? 0);
            $res = (new TenderSubmissionAttachments())->remove($id, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function submissionAttachmentDownloadAction($attachmentId = 0)
    {
        $this->serveSubmissionAttachment((int)$attachmentId, /* inline */ false);
    }

    /** Slice 8U · same file, inline disposition for iframe preview of PDF/image types. */
    public function submissionAttachmentPreviewAction($attachmentId = 0)
    {
        $this->serveSubmissionAttachment((int)$attachmentId, /* inline */ true);
    }

    private function serveSubmissionAttachment(int $attachmentId, bool $inline): void
    {
        if (!class_exists('TenderSubmissionAttachments')) require_once ROOT . '/app/models/TenderSubmissionAttachments.php';
        $m = new TenderSubmissionAttachments();
        $row = $m->find($attachmentId);
        if (!$row) { header('HTTP/1.1 404 Not Found'); echo 'Attachment not found'; return; }
        $abs = $m->absolutePath($row);
        if (!$abs) { header('HTTP/1.1 410 Gone'); echo 'File missing on disk'; return; }
        header('Content-Type: ' . ($row['mime_type'] ?: 'application/octet-stream'));
        $disp = $inline ? 'inline' : 'attachment';
        header('Content-Disposition: ' . $disp . '; filename="' . addslashes((string)$row['original_filename']) . '"');
        header('Content-Length: ' . filesize($abs));
        header('Cache-Control: private, no-cache');
        readfile($abs);
    }

    /* ── Contract signing (Slice 3) ────────────────────────────────────── */

    public function saveContractTermsAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            (new Tenders())->saveContractTerms(
                $id, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['user_group_name'] ?? ''),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_POST['reason'] ?? '')
            );
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function unlockContractTermsAjAction()
    {
        header('Content-Type: application/json');
        try {
            (new Tenders())->unlockTerms(
                (int)($_POST['tender_id'] ?? 0),
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_SESSION['user_group_name'] ?? ''),
                (string)($_POST['reason'] ?? '')
            );
            echo json_encode(['ok' => true, 'msg' => 'Terms unlocked. Finance Manager can now amend; the next save re-locks automatically.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function relockContractTermsAjAction()
    {
        header('Content-Type: application/json');
        try {
            (new Tenders())->relockTerms(
                (int)($_POST['tender_id'] ?? 0),
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'msg' => 'Unlock cancelled — terms locked again.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function submitContractAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            $res = (new Tenders())->submitContractForApproval($id,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function recordSigningAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            (new Tenders())->recordContractSigning($id, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function addContractReceiptAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            $rid = (new Tenders())->addContractReceipt($id, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true, 'receipt_id' => $rid]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function deleteContractReceiptAjAction()
    {
        header('Content-Type: application/json');
        try {
            $rid = (int)($_POST['receipt_id'] ?? 0);
            if ($rid <= 0) throw new InvalidArgumentException('Missing receipt_id.');
            (new Tenders())->deleteContractReceipt($rid);
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Pricing engine (Slice 4) ──────────────────────────────────────── */

    public function pricingCreateVersionAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid   = (int)($_POST['tender_id'] ?? 0);
            $clone = (int)($_POST['clone_from'] ?? 0) ?: null;
            $vid = (new TenderPricing())->createVersion($tid, $clone,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true, 'version_id' => $vid]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingUpdateVersionAjAction()
    {
        header('Content-Type: application/json');
        try {
            $vid = (int)($_POST['version_id'] ?? 0);
            (new TenderPricing())->updateVersionHeader($vid, $_POST, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingAddSectionAjAction()
    {
        header('Content-Type: application/json');
        try {
            $vid = (int)($_POST['version_id'] ?? 0);
            $tid = (int)($_POST['tender_id']  ?? 0);
            $sid = (new TenderPricing())->addSection($vid, $tid,
                (string)($_POST['section_name'] ?? 'New section'),
                (string)($_POST['notes'] ?? ''),
                (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true, 'section_id' => $sid]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingUpdateSectionAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid = (int)($_POST['section_id'] ?? 0);
            (new TenderPricing())->updateSection($sid, $_POST, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingDeleteSectionAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid = (int)($_POST['section_id'] ?? 0);
            (new TenderPricing())->deleteSection($sid);
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingAddLineAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid = (int)($_POST['section_id'] ?? 0);
            $lid = (new TenderPricing())->addLine($sid, $_POST, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true, 'line_id' => $lid]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingUpdateLineAjAction()
    {
        header('Content-Type: application/json');
        try {
            $lid = (int)($_POST['line_id'] ?? 0);
            (new TenderPricing())->updateLine($lid, $_POST, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingDeleteLineAjAction()
    {
        header('Content-Type: application/json');
        try {
            $lid = (int)($_POST['line_id'] ?? 0);
            (new TenderPricing())->deleteLine($lid);
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingSubmitAjAction()
    {
        header('Content-Type: application/json');
        try {
            $vid = (int)($_POST['version_id'] ?? 0);
            $res = (new TenderPricing())->submitForApproval($vid,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_SESSION['user_group_name'] ?? ''));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingApplyAjAction()
    {
        header('Content-Type: application/json');
        try {
            $vid = (int)($_POST['version_id'] ?? 0);
            (new TenderPricing())->applyToTender($vid);
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* Slice 8M · Seed pricing lines from tender_items into the DRAFT version */
    public function pricingSeedFromItemsAjAction()
    {
        header('Content-Type: application/json');
        try {
            $vid = (int)($_POST['version_id'] ?? 0);
            $sid = (int)($_POST['section_id'] ?? 0);
            $res = (new TenderPricing())->seedFromItems($vid, $sid, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* Slice 8M · Full pricing snapshot (used by Vue root to rehydrate) */
    public function pricingSnapshotAjAction()
    {
        header('Content-Type: application/json');
        try {
            $vid = (int)($_POST['version_id'] ?? $_GET['version_id'] ?? 0);
            $snap = (new TenderPricing())->snapshot($vid);
            echo json_encode(['ok' => (bool)$snap, 'snapshot' => $snap]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* Slice 8S · Overhead pool CRUD + template loader */
    public function pricingOverheadsAddAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
            $vid = (int)($_POST['version_id'] ?? 0);
            $tid = (int)($_POST['tender_id']  ?? 0);
            $res = (new PricingVersionOverheads())->add($vid, $tid, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            if ($res['ok']) (new TenderPricing())->rollupVersion($vid);
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingOverheadsUpdateAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
            $id = (int)($_POST['overhead_id'] ?? 0);
            $pvo = new PricingVersionOverheads();
            $cur = $pvo->find($id);
            $res = $pvo->update($id, $_POST, (int)($_SESSION['user_id'] ?? 0));
            if ($res['ok'] && $cur) (new TenderPricing())->rollupVersion((int)$cur['version_id']);
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function pricingOverheadsRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
            $id = (int)($_POST['overhead_id'] ?? 0);
            $pvo = new PricingVersionOverheads();
            $cur = $pvo->find($id);
            $res = $pvo->remove($id, (int)($_SESSION['user_id'] ?? 0));
            if ($res['ok'] && $cur) (new TenderPricing())->rollupVersion((int)$cur['version_id']);
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Quick-load an overhead preset (GOODS_LOCAL / GOODS_IMPORT) into the current draft. */
    public function pricingOverheadsLoadTemplateAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
            $vid = (int)($_POST['version_id'] ?? 0);
            $tid = (int)($_POST['tender_id']  ?? 0);
            $key = (string)($_POST['template'] ?? '');
            $res = (new PricingVersionOverheads())->loadTemplate($vid, $tid, $key,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            if ($res['ok']) (new TenderPricing())->rollupVersion($vid);
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Slice 8S: two audiences.
     *   ?audience=client   → client-facing (respects client_boq_visibility, hides all overheads except PRELIMS_LINE)
     *   ?audience=internal → full audit view (direct cost + all overheads with mode + true margin vs client margin)
     *   default            → internal (backwards-compatible with pre-8S link)
     */
    public function pricingXlsxAction($versionId = 0)
    {
        $vid = (int)$versionId;
        $tp  = new TenderPricing();
        $v = $tp->findVersion($vid);
        if (!$v) { header('HTTP/1.1 404 Not Found'); echo 'Version not found'; return; }
        $t = (new Tenders())->find((int)$v['tender_id']);

        $audience = strtolower((string)($_GET['audience'] ?? 'internal'));
        if ($audience === 'client') $this->pricingXlsxClient($v, $t, $tp);
        else                        $this->pricingXlsxInternal($v, $t, $tp);
    }

    /** Slice 8S PDF exports — warm palette, Roboto Condensed via RsPdfReport.
     *  Slice 8U: ?disposition=attachment forces a download (via Content-Disposition override).
     *            Default is inline (browser renders in iframe / new tab). */
    public function pricingPdfAction($versionId = 0)
    {
        $vid = (int)$versionId;
        $tp  = new TenderPricing();
        $v = $tp->findVersion($vid);
        if (!$v) { header('HTTP/1.1 404 Not Found'); echo 'Version not found'; return; }
        $t = (new Tenders())->find((int)$v['tender_id']);

        // ExportHelper::pdfDownload sets inline via FPDF Output('I'). For explicit download requests
        // we override Content-Disposition AFTER the fact isn't possible with FPDF, so buffer + re-send.
        $wantDownload = strtolower((string)($_GET['disposition'] ?? '')) === 'attachment';
        if ($wantDownload) {
            ob_start();
        }

        $audience = strtolower((string)($_GET['audience'] ?? 'internal'));
        if ($audience === 'client') $this->pricingPdfClient($v, $t, $tp);
        else                        $this->pricingPdfInternal($v, $t, $tp);

        if ($wantDownload) {
            $body = ob_get_clean();
            // Strip inline header (FPDF already sent it) and resend as attachment
            if (!headers_sent()) header_remove('Content-Disposition');
            $fname = 'tender_pricing_v' . $v['version_no'] . '_' . $audience . '.pdf';
            header('Content-Disposition: attachment; filename="' . $fname . '"');
            echo $body;
        }
    }

    /** Client-facing export: respects client_boq_visibility, PRELIMS_LINE overheads visible, DISTRIBUTED are in unit_price already, INTERNAL_ONLY hidden. */
    private function pricingXlsxClient(array $v, array $t, TenderPricing $tp): void
    {
        if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
        $pvo = new PricingVersionOverheads();
        $vid = (int)$v['c0d'];
        $vis = (string)($v['client_boq_visibility'] ?? 'ITEMS_AND_SECTIONS');
        $rows = [];

        // Simple columns (no cost/markup) — buyer must never see them.
        $columns = [
            ['label' => '#',           'width' => 6,  'align' => 'R'],
            ['label' => 'Item ref',    'width' => 14, 'align' => 'L'],
            ['label' => 'Description', 'width' => 46, 'align' => 'L'],
            ['label' => 'Qty',         'width' => 10, 'align' => 'R', 'type' => 'money'],
            ['label' => 'UOM',         'width' => 8,  'align' => 'L'],
            ['label' => 'Unit price',  'width' => 16, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Line total',  'width' => 18, 'align' => 'R', 'type' => 'money'],
        ];

        if ($vis === 'LUMPSUM_ONLY') {
            $rows[] = ['', 'Lump-sum bid', 'All-inclusive fixed price for full scope of works.', null, null, null, (float)$v['sum_price']];
        } else {
            // Slice 8S: distribute the DISTRIBUTED overhead pool across item lines pro-rata by line_price
            // so the client-facing unit prices are what the buyer actually sees on their BOQ.
            $distributedPool = 0.0;
            foreach ($pvo->listFor($vid) as $oh) {
                if ($oh['display_mode'] === 'DISTRIBUTED') $distributedPool += (float)$oh['amount'];
            }
            $directLineTotal = 0.0;
            foreach ($tp->sectionsFor($vid) as $s) {
                foreach ($tp->linesFor((int)$s['c0d']) as $ln) $directLineTotal += (float)$ln['line_price'];
            }
            $shouldDistribute = ($distributedPool > 0 && $directLineTotal > 0);

            foreach ($tp->sectionsFor($vid) as $s) {
                if ($vis === 'ITEMS_AND_SECTIONS') {
                    $rows[] = ['', '— ' . $s['section_name'] . ' —', $s['notes'] ?? '', '', '', '', ''];
                }
                foreach ($tp->linesFor((int)$s['c0d']) as $ln) {
                    $rawLinePrice = (float)$ln['line_price'];
                    $qty          = (float)$ln['qty'];
                    if ($shouldDistribute) {
                        $share = ($rawLinePrice / $directLineTotal) * $distributedPool;
                        $clientLinePrice = round($rawLinePrice + $share, 2);
                        $clientUnitPrice = $qty > 0 ? round($clientLinePrice / $qty, 4) : (float)$ln['unit_price'];
                    } else {
                        $clientLinePrice = $rawLinePrice;
                        $clientUnitPrice = (float)$ln['unit_price'];
                    }
                    $rows[] = [$ln['line_no'], $ln['item_ref'], $ln['description'],
                               $qty, $ln['uom'],
                               $clientUnitPrice, $clientLinePrice];
                }
            }
            // Prelims-line overheads (client visible).
            $prelimsOh = array_filter($pvo->listFor($vid), fn($o) => $o['display_mode'] === 'PRELIMS_LINE');
            if (!empty($prelimsOh) || (float)$v['prelims_amount'] > 0) {
                $rows[] = ['', '', '', '', '', '', ''];
                $rows[] = ['', '— Preliminaries —', '', '', '', '', ''];
                if ((float)$v['prelims_amount'] > 0) {
                    $rows[] = ['', 'PRE', 'Fixed preliminaries', 1, 'lot', (float)$v['prelims_amount'], (float)$v['prelims_amount']];
                }
                foreach ($prelimsOh as $oh) {
                    $rows[] = ['', $oh['category'], $oh['label'], 1, 'lot', (float)$oh['amount'], (float)$oh['amount']];
                }
            }
        }
        $totals = [['label' => 'Bid total (' . $v['currency'] . ')', 'values' => [null, null, null, null, null, null, (float)$v['sum_price']]]];
        $title = 'Bid Submission · ' . $t['tender_no'] . ' · v' . $v['version_no'];
        $sub   = $t['tender_title'] . '  ·  ' . $t['buyer_name'] . '  ·  currency ' . $v['currency'];
        $fname = 'bid_' . $t['tender_no'] . '_v' . $v['version_no'] . '_client';
        ExportHelper::xlsxDownload($fname . '.xlsx', 'Bid', $columns, $rows, $totals, ['title' => $title, 'subtitle' => $sub]);
    }

    /** Internal export: full breakdown — direct cost, all overheads with mode, true margin vs client margin. */
    private function pricingXlsxInternal(array $v, array $t, TenderPricing $tp): void
    {
        if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
        $pvo = new PricingVersionOverheads();
        $vid = (int)$v['c0d'];

        $rows = [];
        foreach ($tp->sectionsFor($vid) as $s) {
            $rows[] = ['', '— ' . $s['section_name'] . ' —', '', '', '', '', '', '', '', ''];
            foreach ($tp->linesFor((int)$s['c0d']) as $ln) {
                $rows[] = [$ln['line_no'], $ln['item_ref'], $ln['description'],
                           (float)$ln['qty'], $ln['uom'],
                           (float)$ln['unit_cost'], (float)$ln['markup_pct'], (float)$ln['unit_price'],
                           (float)$ln['line_cost'], (float)$ln['line_price']];
            }
            $rows[] = ['', 'Section subtotal', '', '', '', (float)$s['sum_cost'], null, null, (float)$s['sum_cost'], (float)$s['sum_price']];
        }
        // Overhead breakdown by display_mode.
        $rows[] = ['', '', '', '', '', '', '', '', '', ''];
        $rows[] = ['', '— Overhead pool (internal) —', 'category · mode', '', '', '', '', '', '', ''];
        foreach ($pvo->listFor($vid) as $oh) {
            $rows[] = ['', $oh['label'], $oh['category'] . ' · ' . $oh['display_mode'],
                       '', '', (float)$oh['amount'], null, null, (float)$oh['amount'], null];
        }
        // Client vs true margin summary.
        $sumOverhead = (float)($v['sum_overhead'] ?? 0);
        $directCost  = (float)$v['sum_cost'] - $sumOverhead;
        $clientMargin = (float)$v['sum_price'] > 0 ? (((float)$v['sum_price'] - $directCost) / (float)$v['sum_price']) * 100 : 0;
        $rows[] = ['', '', '', '', '', '', '', '', '', ''];
        $rows[] = ['', 'Direct cost (items only)',    '', '', '', $directCost,              null, null, null, null];
        $rows[] = ['', 'Overhead pool total',        '', '', '', $sumOverhead,             null, null, null, null];
        $rows[] = ['', 'Internal true cost',         '', '', '', (float)$v['sum_cost'],    null, null, null, null];
        $rows[] = ['', 'Prelims (visible)',          '', '', '', null,                     null, null, null, (float)$v['prelims_amount']];
        $rows[] = ['', 'Client-facing margin %',     '',        '', '', null, null, null, null, round($clientMargin, 2)];
        $rows[] = ['', 'True margin % (post-oh)',    '',        '', '', null, null, null, null, (float)$v['margin_pct']];

        $columns = [
            ['label' => '#',            'width' => 6,  'align' => 'R'],
            ['label' => 'Item ref',     'width' => 14, 'align' => 'L'],
            ['label' => 'Description',  'width' => 34, 'align' => 'L'],
            ['label' => 'Qty',          'width' => 8,  'align' => 'R', 'type' => 'money'],
            ['label' => 'UOM',          'width' => 8,  'align' => 'L'],
            ['label' => 'Unit cost',    'width' => 12, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Markup %',     'width' => 10, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Unit price',   'width' => 12, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Line cost',    'width' => 14, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Line price',   'width' => 14, 'align' => 'R', 'type' => 'money'],
        ];
        $totals = [['label' => 'Bid total (' . $v['currency'] . ')', 'values' => [null, null, null, null, null, (float)$v['sum_cost'], null, null, (float)$v['sum_cost'], (float)$v['sum_price']]]];
        $title = 'Internal · Tender pricing · ' . $t['tender_no'] . ' · v' . $v['version_no'];
        $sub   = $t['tender_title'] . '  ·  status ' . strtolower((string)$v['status']) . '  ·  true margin ' . number_format((float)$v['margin_pct'], 1) . '%  ·  client margin ' . number_format($clientMargin, 1) . '%';
        $fname = 'tender_pricing_' . $t['tender_no'] . '_v' . $v['version_no'] . '_internal';
        ExportHelper::xlsxDownload($fname . '.xlsx', 'Pricing', $columns, $rows, $totals, ['title' => $title, 'subtitle' => $sub]);
    }

    /**
     * Slice 8S · Client-facing PDF — warm palette, Roboto Condensed via RsPdfReport.
     * Mirrors pricingXlsxClient: respects client_boq_visibility, applies pro-rata distribution to line prices,
     * hides all overhead detail except PRELIMS_LINE.
     */
    private function pricingPdfClient(array $v, array $t, TenderPricing $tp): void
    {
        if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
        $pvo = new PricingVersionOverheads();
        $vid = (int)$v['c0d'];
        $vis = (string)($v['client_boq_visibility'] ?? 'ITEMS_AND_SECTIONS');

        $columns = [
            ['label' => '#',           'width' => 8,  'align' => 'R'],
            ['label' => 'Item ref',    'width' => 20, 'align' => 'L'],
            ['label' => 'Description', 'width' => 70, 'align' => 'L'],
            ['label' => 'Qty',         'width' => 15, 'align' => 'R', 'type' => 'money'],
            ['label' => 'UOM',         'width' => 12, 'align' => 'L'],
            ['label' => 'Unit price',  'width' => 25, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Line total',  'width' => 30, 'align' => 'R', 'type' => 'money'],
        ];
        $rows = [];

        if ($vis === 'LUMPSUM_ONLY') {
            $rows[] = ['', 'Lump-sum bid', 'All-inclusive fixed price for full scope of works.', null, null, null, (float)$v['sum_price']];
        } else {
            $distributedPool = 0.0;
            foreach ($pvo->listFor($vid) as $oh) if ($oh['display_mode'] === 'DISTRIBUTED') $distributedPool += (float)$oh['amount'];
            $directLineTotal = 0.0;
            foreach ($tp->sectionsFor($vid) as $s) foreach ($tp->linesFor((int)$s['c0d']) as $ln) $directLineTotal += (float)$ln['line_price'];
            $shouldDistribute = ($distributedPool > 0 && $directLineTotal > 0);

            foreach ($tp->sectionsFor($vid) as $s) {
                if ($vis === 'ITEMS_AND_SECTIONS') {
                    $rows[] = ['', '— ' . $s['section_name'] . ' —', $s['notes'] ?? '', '', '', '', ''];
                }
                foreach ($tp->linesFor((int)$s['c0d']) as $ln) {
                    $raw = (float)$ln['line_price']; $qty = (float)$ln['qty'];
                    if ($shouldDistribute) {
                        $share = ($raw / $directLineTotal) * $distributedPool;
                        $clientLine = round($raw + $share, 2);
                        $clientUnit = $qty > 0 ? round($clientLine / $qty, 4) : (float)$ln['unit_price'];
                    } else {
                        $clientLine = $raw; $clientUnit = (float)$ln['unit_price'];
                    }
                    $rows[] = [$ln['line_no'], $ln['item_ref'], $ln['description'], $qty, $ln['uom'], $clientUnit, $clientLine];
                }
            }
            $prelimsOh = array_filter($pvo->listFor($vid), fn($o) => $o['display_mode'] === 'PRELIMS_LINE');
            if (!empty($prelimsOh) || (float)$v['prelims_amount'] > 0) {
                $rows[] = ['', '', '', '', '', '', ''];
                $rows[] = ['', '— Preliminaries —', '', '', '', '', ''];
                if ((float)$v['prelims_amount'] > 0) {
                    $rows[] = ['', 'PRE', 'Fixed preliminaries', 1, 'lot', (float)$v['prelims_amount'], (float)$v['prelims_amount']];
                }
                foreach ($prelimsOh as $oh) {
                    $rows[] = ['', $oh['category'], $oh['label'], 1, 'lot', (float)$oh['amount'], (float)$oh['amount']];
                }
            }
        }
        $totals = [['label' => 'Bid total (' . $v['currency'] . ')', 'values' => [null, null, null, null, null, null, (float)$v['sum_price']]]];
        $title = 'Bid submission · ' . $t['tender_no'] . ' · v' . $v['version_no'];
        $sub   = $t['tender_title'] . '  ·  ' . $t['buyer_name'] . '  ·  currency ' . $v['currency'];
        $fname = 'bid_' . $t['tender_no'] . '_v' . $v['version_no'] . '_client';
        ExportHelper::pdfDownload($fname . '.pdf', $title, $sub, $columns, $rows, $totals, 'P');
    }

    /**
     * Slice 8S · Internal-facing PDF — full breakdown (direct cost + all overheads + true vs client margin).
     * Landscape because the internal view has 10 columns (cost + markup + unit_price + line_cost + line_price).
     */
    private function pricingPdfInternal(array $v, array $t, TenderPricing $tp): void
    {
        if (!class_exists('PricingVersionOverheads')) require_once ROOT . '/app/models/PricingVersionOverheads.php';
        $pvo = new PricingVersionOverheads();
        $vid = (int)$v['c0d'];

        $rows = [];
        foreach ($tp->sectionsFor($vid) as $s) {
            $rows[] = ['', '— ' . $s['section_name'] . ' —', '', '', '', '', '', '', '', ''];
            foreach ($tp->linesFor((int)$s['c0d']) as $ln) {
                $rows[] = [$ln['line_no'], $ln['item_ref'], $ln['description'],
                           (float)$ln['qty'], $ln['uom'],
                           (float)$ln['unit_cost'], (float)$ln['markup_pct'], (float)$ln['unit_price'],
                           (float)$ln['line_cost'], (float)$ln['line_price']];
            }
            $rows[] = ['', 'Section subtotal', '', '', '', (float)$s['sum_cost'], null, null, (float)$s['sum_cost'], (float)$s['sum_price']];
        }
        $rows[] = ['', '', '', '', '', '', '', '', '', ''];
        $rows[] = ['', '— Overhead pool (internal) —', 'category · mode', '', '', '', '', '', '', ''];
        foreach ($pvo->listFor($vid) as $oh) {
            $rows[] = ['', $oh['label'], $oh['category'] . ' · ' . $oh['display_mode'],
                       '', '', (float)$oh['amount'], null, null, (float)$oh['amount'], null];
        }
        $sumOverhead = (float)($v['sum_overhead'] ?? 0);
        $directCost  = (float)$v['sum_cost'] - $sumOverhead;
        $clientMargin = (float)$v['sum_price'] > 0 ? (((float)$v['sum_price'] - $directCost) / (float)$v['sum_price']) * 100 : 0;
        $rows[] = ['', '', '', '', '', '', '', '', '', ''];
        $rows[] = ['', 'Direct cost (items only)',    '', '', '', $directCost,              null, null, null, null];
        $rows[] = ['', 'Overhead pool total',        '', '', '', $sumOverhead,             null, null, null, null];
        $rows[] = ['', 'Internal true cost',         '', '', '', (float)$v['sum_cost'],    null, null, null, null];
        $rows[] = ['', 'Prelims (visible)',          '', '', '', null,                     null, null, null, (float)$v['prelims_amount']];
        $rows[] = ['', 'Client-facing margin %',     '', '', '', null, null, null, null, round($clientMargin, 2)];
        $rows[] = ['', 'True margin % (post-oh)',    '', '', '', null, null, null, null, (float)$v['margin_pct']];

        $columns = [
            ['label' => '#',            'width' => 8,  'align' => 'R'],
            ['label' => 'Item ref',     'width' => 18, 'align' => 'L'],
            ['label' => 'Description',  'width' => 55, 'align' => 'L'],
            ['label' => 'Qty',          'width' => 12, 'align' => 'R', 'type' => 'money'],
            ['label' => 'UOM',          'width' => 10, 'align' => 'L'],
            ['label' => 'Unit cost',    'width' => 20, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Markup %',     'width' => 14, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Unit price',   'width' => 20, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Line cost',    'width' => 22, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Line price',   'width' => 22, 'align' => 'R', 'type' => 'money'],
        ];
        $totals = [['label' => 'Bid total (' . $v['currency'] . ')', 'values' => [null, null, null, null, null, (float)$v['sum_cost'], null, null, (float)$v['sum_cost'], (float)$v['sum_price']]]];
        $title = 'Internal · Tender pricing · ' . $t['tender_no'] . ' · v' . $v['version_no'];
        $sub   = $t['tender_title'] . '  ·  status ' . strtolower((string)$v['status']) . '  ·  true margin ' . number_format((float)$v['margin_pct'], 1) . '%  ·  client margin ' . number_format($clientMargin, 1) . '%';
        $fname = 'tender_pricing_' . $t['tender_no'] . '_v' . $v['version_no'] . '_internal';
        ExportHelper::pdfDownload($fname . '.pdf', $title, $sub, $columns, $rows, $totals, 'L');
    }

    /* ── AJAX (stage / bidder / result / convert / export) ────────────── */

    public function advanceStageAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id     = (int)($_POST['tender_id'] ?? 0);
            $stage  = (string)($_POST['stage'] ?? '');
            $notes  = trim((string)($_POST['notes'] ?? ''));
            (new Tenders())->advanceStage($id, $stage, $notes, (int)($_SESSION['user_id'] ?? 0), (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function addBidderAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id     = (int)($_POST['tender_id'] ?? 0);
            $name   = trim((string)($_POST['bidder_name'] ?? ''));
            $isUs   = !empty($_POST['is_us']);
            $amount = (float)($_POST['bid_amount'] ?? 0);
            $rank   = $_POST['ranking'] !== '' ? (int)$_POST['ranking'] : null;
            $notes  = trim((string)($_POST['notes'] ?? ''));
            if ($id <= 0 || $name === '') throw new InvalidArgumentException('Tender + bidder name required.');
            (new Tenders())->addBidder($id, $name, $isUs, $amount, $rank, $notes, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function recordResultAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id     = (int)($_POST['tender_id'] ?? 0);
            $status = (string)($_POST['result_status'] ?? '');
            $date   = (string)($_POST['result_date'] ?? date('Y-m-d'));
            $winner = trim((string)($_POST['winning_bidder'] ?? ''));
            $wAmt   = $_POST['winning_amount'] !== '' ? (float)$_POST['winning_amount'] : null;
            $reason = trim((string)($_POST['reason'] ?? ''));
            $less   = trim((string)($_POST['lessons'] ?? ''));
            $cat    = trim((string)($_POST['reason_category'] ?? '')); // Slice 8W
            (new Tenders())->recordResult($id, $status, $date, $winner, $wAmt, $reason, $less,
                (int)($_SESSION['user_id'] ?? 0), (string)($_SESSION['full_name'] ?? ''),
                $cat !== '' ? $cat : null);
            // Slice 8W: return the fresh result + tender stage/status so the Vue root can re-hydrate without a full reload.
            $tm = new Tenders();
            $t  = $tm->find($id);
            echo json_encode([
                'ok' => true,
                'result' => $tm->result($id),
                'tender_stage'  => $t['tender_stage']  ?? null,
                'tender_status' => $t['tender_status'] ?? null,
            ]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function convertAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['tender_id'] ?? 0);
            $pid = (new Tenders())->convertToProject($id, (int)($_SESSION['user_id'] ?? 0), (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true, 'project_id' => $pid]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Items (Slice 8L) · buyer-facing item list · CRM-authored ─────── */

    public function itemsListAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? $_GET['tender_id'] ?? 0);
            $items = (new TenderItems())->listFor($tid);
            $tender = (new Tenders())->find($tid);
            echo json_encode([
                'ok'           => true,
                'items'        => $items,
                'default_kind' => $tender ? TenderItems::defaultKindFor((string)$tender['project_type']) : 'GOOD',
                'project_type' => $tender['project_type'] ?? null,
            ]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function itemsAddAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['tender_id'] ?? 0);
            $res = (new TenderItems())->add($tid, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function itemsUpdateAjAction()
    {
        header('Content-Type: application/json');
        try {
            $iid = (int)($_POST['item_id'] ?? 0);
            $res = (new TenderItems())->update($iid, $_POST, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function itemsRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $iid = (int)($_POST['item_id'] ?? 0);
            $res = (new TenderItems())->remove($iid, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function itemsReorderAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid   = (int)($_POST['tender_id'] ?? 0);
            $order = (array)($_POST['order'] ?? []);
            $res   = (new TenderItems())->reorder($tid, $order);
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Bulk import from JSON body: rows = [{description, qty, uom, item_ref, spec_notes, line_kind}, …] */
    public function itemsBulkImportAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid  = (int)($_POST['tender_id'] ?? 0);
            $rows = $_POST['rows'] ?? '[]';
            if (is_string($rows)) $rows = json_decode($rows, true) ?: [];
            $res  = (new TenderItems())->bulkImport($tid, (array)$rows,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Exports ───────────────────────────────────────────────────────── */

    public function xlsxAction() { $this->exportRegister('xlsx'); }
    public function pdfAction()  { $this->exportRegister('pdf'); }

    private function exportRegister(string $format): void
    {
        $filter = [
            'stage'        => (string)($_GET['stage']  ?? ''),
            'source'       => (string)($_GET['source'] ?? ''),
            'status'       => (string)($_GET['status'] ?? ''),
            'project_type' => (string)($_GET['type']   ?? ''),
            'q'            => trim((string)($_GET['q'] ?? '')),
        ];
        $rows = (new Tenders())->listAll($filter);
        $data = []; $sumEst = 0; $sumSub = 0;
        foreach ($rows as $t) {
            $data[] = [$t['tender_no'], $t['tender_title'], $t['buyer_name'], $t['source_type'],
                       $t['project_type'], (float)$t['estimated_value'], (float)$t['submission_amount'],
                       $t['tender_stage'], $t['tender_status'], $t['deadline_date'] ?? ''];
            $sumEst += (float)$t['estimated_value']; $sumSub += (float)$t['submission_amount'];
        }
        $columns = [
            ['label' => 'Tender #',    'width' => 14, 'align' => 'L'],
            ['label' => 'Title',       'width' => 34, 'align' => 'L'],
            ['label' => 'Buyer',       'width' => 26, 'align' => 'L'],
            ['label' => 'Source',      'width' => 14, 'align' => 'L'],
            ['label' => 'Type',        'width' => 10, 'align' => 'L'],
            ['label' => 'Estimated',   'width' => 16, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Submitted',   'width' => 16, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Stage',       'width' => 16, 'align' => 'L'],
            ['label' => 'Status',      'width' => 12, 'align' => 'L'],
            ['label' => 'Deadline',    'width' => 12, 'align' => 'L', 'type' => 'date'],
        ];
        $totals = [['label' => 'Totals', 'values' => [null, null, null, null, null, $sumEst, $sumSub, null, null, null]]];
        $title = 'Tender Register';
        $sub   = date('Y-m-d') . ' · ' . count($rows) . ' tender(s)';
        $fname = 'tender_register_' . date('Y-m-d');
        if ($format === 'xlsx') {
            ExportHelper::xlsxDownload($fname . '.xlsx', 'Tenders', $columns, $data, $totals, ['title' => $title, 'subtitle' => $sub]);
        } else {
            ExportHelper::pdfDownload($fname . '.pdf', $title, $sub, $columns, $data, $totals, 'L');
        }
    }
}
