<?php
declare(strict_types=1);

// AUTH_GUARD_AUTOGEN
if (!function_exists('tp_guard')) {
  require_once (is_file(__DIR__ . '/_auth_guard.php') ? __DIR__ . '/_auth_guard.php' : dirname(__DIR__) . '/_auth_guard.php');
}
$TP  = tp_guard();
$pdo = $TP['pdo'];
require_once __DIR__ . '/_company_scope.php';
require_once __DIR__ . '/_load_carrier_lib.php';
require_once __DIR__ . '/_transport_lock.php';
// 19-08-2026: besparing per voorstel (losse ritprijzen vs. combiprijs).
if (is_file(__DIR__ . '/_combo_savings_lib.php')) { require_once __DIR__ . '/_combo_savings_lib.php'; }
$__tp_live_events_helper = __DIR__ . '/../../api/common/live_events.php';
if (is_file($__tp_live_events_helper)) { require_once $__tp_live_events_helper; }
if (function_exists('planner_live_ensure_table')) {
  try { planner_live_ensure_table($pdo); } catch (Throwable $e) { error_log('combos live table ensure failed: ' . $e->getMessage()); }
}


/**
 * combos-full-guard-2025-09-01T19-45.php
 * Fail-proof combos endpoint with:
 * - global fatal error JSON handler
 * - strict list filters and empty-set guards
 * - rule-based ensure generator
 */

header('Content-Type: application/json; charset=utf-8');
error_reporting(E_ALL);
ini_set('display_errors','0');

// Convert warnings/notices to exceptions so we can JSON them
set_error_handler(function($severity, $message, $file, $line){
  if (!(error_reporting() & $severity)) return false;
  throw new ErrorException($message, 0, $severity, $file, $line);
});

// Fatal handler → always JSON (no empty 500s)
register_shutdown_function(function() {
  $e = error_get_last();
  if ($e && in_array($e['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) { 
    http_response_code(500);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['ok'=>false,'fatal'=>$e,'version'=>'combos-full-guard-2025-09-01T19-45'], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
  }
});

function out(array $p, int $code=200){ http_response_code($code); echo json_encode($p, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); exit; }
function bad(string $m, int $code=400){ out(['ok'=>false,'error'=>$m], $code); }

// DB: gebruik de PDO uit tp_guard() zodat we altijd dezelfde database gebruiken als de rest van de app.

function toISO(?string $s): ?string {
  if ($s===null) return null;
  $s=trim($s);
  if ($s==='') return null;
  if (preg_match('~^\d{4}-\d{2}-\d{2}$~',$s)) return $s;
  if (preg_match('~^(\d{2})-(\d{2})-(\d{4})$~',$s,$m)) return "$m[3]-$m[2]-$m[1]";
  return date('Y-m-d', strtotime($s));
}

// -------- data helpers --------
function loadItemsFor(PDO $pdo, array $ids): array {
  global $TP;
  if (empty($ids)) return [];
  $params = [];
  $ph = [];
  foreach (array_values($ids) as $i => $id) { $key=':id' . $i; $ph[]=$key; $params[$key]=(int)$id; }
  $scope = tp_scope_sql($pdo, 'combo_proposals', 'p', $TP, $params, 'li_');
  $loadCarrierSelect = (hasColumn($pdo, 'planned_transports', 'load_carrier_type') ? ", t.load_carrier_type" : ", 'pallet' AS load_carrier_type")
    . (hasColumn($pdo, 'planned_transports', 'load_carrier_qty') ? ", t.load_carrier_qty" : ", t.pallets AS load_carrier_qty");
  // 01-09-2026: losnummer meesturen zodat het voorstel het routenummer van de
  // klant kan tonen; de planner herkent een rit daar sneller aan dan aan de
  // bestemming alleen.
  $losnrSelect = hasColumn($pdo, 'planned_transports', 'losnummer') ? ", t.losnummer" : ", '' AS losnummer";
  $sql = "SELECT i.proposal_id, i.transport_id, i.seq, i.sort_order,
                 t.pallets" . $loadCarrierSelect . $losnrSelect . ", t.losbestemming, t.combo_id, t.vervoerder
          FROM combo_proposal_items i
          JOIN combo_proposals p ON p.id = i.proposal_id
          JOIN planned_transports t ON t.id = i.transport_id
          WHERE i.proposal_id IN (" . implode(',', $ph) . ")
            AND $scope
          ORDER BY i.proposal_id, i.seq";
  $st = $pdo->prepare($sql); $st->execute($params);
  $rows = $st->fetchAll();
  $by=[];
  foreach($rows as $r){
    $r = tp_lc_enrich_row($r);
    $by[(int)$r['proposal_id']][]=$r;
  }
  return $by;
}

function listForDate(PDO $pdo, string $date, string $status='pending'): array {
  global $TP;
  $params = [':d'=>$date, ':s'=>$status];
  $proposalScope = tp_scope_sql($pdo, 'combo_proposals', 'p', $TP, $params, 'lp_');
  if ($status === 'pending') {
    $sql = "SELECT
      p.id, p.status, p.plan_datum, p.source, p.label, p.unload_order, p.total_pallets,
      p.uniq_key, p.created_by, p.created_at, p.decided_by, p.decided_at, p.transport_set_key
    FROM combo_proposals p
    WHERE $proposalScope
      AND p.plan_datum = :d
      AND (p.status = :s OR p.status IS NULL OR p.status = '' OR p.status = 'open')
      AND NOT EXISTS (
        SELECT 1
        FROM combo_proposal_items i
        JOIN planned_transports t ON t.id = i.transport_id
        WHERE i.proposal_id = p.id
          AND (COALESCE(t.pallets, 0) <= 0
               OR COALESCE(t.combo_id,0) <> 0
               OR " . combo_departed_sql($pdo, 't') . ")
      )
      AND (
        SELECT SUM(COALESCE(t2.pallets, 0))
        FROM combo_proposal_items i2
        JOIN planned_transports t2 ON t2.id = i2.transport_id
        WHERE i2.proposal_id = p.id
      ) <= 33
    ORDER BY p.created_at DESC, p.id DESC";
  } else {
    // 03-09-2026: de dagselectie op index_v2 is op DATUM LADEN. Een combi die
    // vóór die wijziging is geaccepteerd heeft plan_datum = losdatum; bij
    // voorladen (laden ≠ lossen) viel hij daardoor buiten de daglijst en kreeg
    // elke rij een eigen A/V-klokje. Daarom: plan_datum óf de laaddatum van
    // een rit in de combi.
    $params[':d2'] = $date;
    $sql="SELECT p.id,p.status,p.plan_datum,p.source,p.label,p.unload_order,p.total_pallets,
                 p.uniq_key,p.created_by,p.created_at,p.decided_by,p.decided_at,p.transport_set_key
          FROM combo_proposals p
          WHERE $proposalScope AND p.status=:s
            AND (p.plan_datum=:d OR EXISTS (
                  SELECT 1 FROM combo_proposal_items i JOIN planned_transports t ON t.id = i.transport_id
                   WHERE i.proposal_id = p.id AND (t.datum = :d2 OR t.datum_laden = :d3)))
          ORDER BY p.created_at DESC, p.id DESC";
  }
  $st=$pdo->prepare($sql);
  $st->execute($params);
  $rows=$st->fetchAll();
  if (!$rows) return [];
  $ids=[]; foreach($rows as $r){ $ids[]=(int)$r['id']; }
  $items = !empty($ids) ? loadItemsFor($pdo,$ids) : [];
  foreach($rows as &$r){
    $rid=(int)$r['id'];
    $r['items']=$items[$rid]??[];
    $r['total_ground_places'] = tp_lc_decimal($r['total_pallets'] ?? 0);
  }

  unset($r);

  // Filter out proposals without enough items (prevents "ghost" pending proposals)
  // This also ensures the frontend list matches what can actually be rendered/accepted.
  $rows = array_values(array_filter($rows, function($r){
    return isset($r['items']) && is_array($r['items']) && count($r['items']) >= 2;
  }));
  if (!$rows) return [];



/* TPC: attach rule overrides */
try{
  $rules = fetch_active_rules($pdo, $date);
  $map = [];
  
foreach($rules as $rr){
  $key = '';
  // Prefer the full ordered stops string (supports >2 stops). Keep max 4 stops in key to match generator.
  $stops = $rr['stops'] ?? null;
  if (is_array($stops) && count($stops) >= 2) {
    $stops = array_slice(array_values(array_filter(array_map('trim', $stops), fn($s)=>$s!=='')), 0, 4);
    if (count($stops) >= 2) $key = implode(' > ', $stops);
  }
  // Fallback: legacy dest_a/dest_b
  if ($key === '') {
    $a = trim((string)($rr['dest_a'] ?? ''));
    $b = trim((string)($rr['dest_b'] ?? ''));
    if ($a !== '' && $b !== '') $key = $a.' > '.$b;
  }
  if ($key === '') continue;

  $map[$key] = [
    'carrier_override'  => $rr['carrier_override'] ?? null,
    'dock_override'     => isset($rr['dock_override']) ? (int)$rr['dock_override'] : null,
    'remarks_template'  => $rr['remarks_template'] ?? null,
  ];
}
  foreach($rows as &$r){
    $uo = (string)($r['unload_order'] ?? $r['label'] ?? '');
    if ($uo !== '' && isset($map[$uo])) {
      $r['carrier_override'] = $map[$uo]['carrier_override'];
      $r['dock_override'] = $map[$uo]['dock_override'];
      $r['remarks_template'] = $map[$uo]['remarks_template'];
    }
  }
}catch(Throwable $e){ /* non-fatal */ }

/* TPC 19-08-2026: besparing per voorstel meesturen.
   De planner ziet nu alleen grondplaatsen, en dat is een ander getal dan wat
   de combinatie oplevert. Ontbreekt een tarief, dan sturen we een reden mee
   in plaats van een 0 of een schatting. Volledig non-fataal: klapt dit om,
   dan blijft de lijst gewoon werken zonder bedragen. */
try{
  if (function_exists('cs_compute')) {
    $tariffs = cs_load_tariffs($pdo, $TP, $date);
    foreach($rows as &$r){
      $stops = [];
      foreach (($r['items'] ?? []) as $it) {
        $d = trim((string)($it['losbestemming'] ?? ''));
        if ($d !== '') $stops[] = $d;
      }
      // carrier_override wint, anders de vervoerder van de eerste rit
      $carrier = trim((string)($r['carrier_override'] ?? ''));
      if ($carrier === '' && !empty($r['items'])) {
        $carrier = trim((string)($r['items'][0]['vervoerder'] ?? ''));
      }
      $r['cost'] = cs_compute($tariffs, $carrier, $stops, $date);
    }
    unset($r);
  }
}catch(Throwable $e){ /* non-fatal */ }
  return $rows;
}

// -------- ensure (rule-based) --------
function hasColumn(PDO $pdo, string $table, string $col): bool {
  static $cache=[]; $k="$table::$col"; if(isset($cache[$k])) return $cache[$k];
  $st=$pdo->prepare("SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:t AND COLUMN_NAME=:c");
  $st->execute([':t'=>$table, ':c'=>$col]); $cache[$k]=(bool)$st->fetch();
  return $cache[$k];
}
function daybit_for_date(string $date): int { $n=(int)date('N', strtotime($date)); return 1 << ($n-1); }
function fetch_active_rules(PDO $pdo, string $date, ?int $companyId = null): array {
  $bit = daybit_for_date($date);
  // FIX 25-08-2026: regels zijn vestigingsgebonden (combo_rules.company_id)
  // maar werden hier ZONDER company-filter opgehaald. Zolang alleen Son regels
  // had viel dat niet op; met regels voor meerdere vestigingen zouden ze
  // kriskras door elkaar gaan lopen. NULL op de regel blijft 'geldt overal'.
  // Pull all stops per rule (supports 2..N stops). We keep dest_a/dest_b for backward-compatible overrides.
  $companyCond = ($companyId !== null && $companyId > 0)
    ? ' AND (r.company_id IS NULL OR r.company_id = ' . (int)$companyId . ')'
    : '';
  $sql = "SELECT r.id, r.weekdays_mask, r.max_pallets, r.carrier_override, r.dock_override, r.remarks_template,
                 GROUP_CONCAT(s.dest_name ORDER BY s.seq SEPARATOR '||') AS stops_csv,
                 MAX(CASE WHEN s.seq=1 THEN s.dest_name END) AS dest_a,
                 MAX(CASE WHEN s.seq=2 THEN s.dest_name END) AS dest_b
          FROM combo_rules r
          JOIN combo_rule_stops s ON s.rule_id = r.id
          WHERE r.active = 1" . $companyCond . "
          GROUP BY r.id, r.weekdays_mask, r.max_pallets, r.carrier_override, r.dock_override, r.remarks_template
          ORDER BY r.id ASC";
  $rules = $pdo->query($sql)->fetchAll();
  $out=[];
  foreach($rules as $r){
    $mask=(int)($r['weekdays_mask'] ?? 127);
    if (($mask & $bit)===0) continue;
    $csv = (string)($r['stops_csv'] ?? '');
    $stops = $csv !== '' ? explode('||', $csv) : [];
    $stops = array_values(array_filter(array_map('trim', $stops), fn($s)=>$s!==''));
    if (count($stops) < 2) continue;
    $r['stops'] = $stops;
    $out[] = $r;
  }
  return $out;
}

/* ---------------------------------------------------------------------------
 * 01-09-2026 — vertrokken ritten horen niet meer in een combinatievoorstel.
 * Best plant vrijwel alles op de dag zelf, dus een voorstel dat 's ochtends is
 * gegenereerd kan halverwege de dag een rit bevatten die al fysiek weg is.
 * Deze conditie sluit die uit, zowel bij het GENEREREN (fetch_candidates) als
 * bij het TONEN (listForDate) — dat tweede is nodig omdat een bestaand voorstel
 * anders blijft staan zodra de wagen vertrekt.
 * Geeft een SQL-fragment terug dat waar is als de rit AL WEG is.
 * ------------------------------------------------------------------------ */
function combo_departed_sql(PDO $pdo, string $alias): string {
  $a = $alias !== '' ? $alias . '.' : '';
  $parts = [];
  if (hasColumn($pdo, 'planned_transports', 'chauffeur_vertrokken_at')) {
    $parts[] = $a . 'chauffeur_vertrokken_at IS NOT NULL';
  }
  if (hasColumn($pdo, 'planned_transports', 'chauffeur_vertrokken')) {
    $parts[] = 'COALESCE(' . $a . 'chauffeur_vertrokken, 0) = 1';
  }
  if (hasColumn($pdo, 'planned_transports', 'status')) {
    $parts[] = "LOWER(TRIM(COALESCE(" . $a . "status,''))) IN ('chauffeur_vertrokken','vertrokken','departed','driver_left','driver_departed','left')";
  }
  if (!$parts) return '0';
  return '(' . implode(' OR ', $parts) . ')';
}

function fetch_candidates(PDO $pdo, string $date, string $dest): array {
  global $TP;
  $useDatumLossen = hasColumn($pdo,'planned_transports','datum_lossen');
  $useDatumLaden  = hasColumn($pdo,'planned_transports','datum_laden'); // fallback for legacy schemas
  $useDatum       = hasColumn($pdo,'planned_transports','datum');       // legacy single date column
  $useStatus      = hasColumn($pdo,'planned_transports','status');

  $dest = trim((string)$dest);
  if ($dest === '') return [];

  // DATUMFILTER (03-09-2026): sluit aan bij index_v2 — de dagselectie is gericht
  // op DATUM LADEN. Voorheen datum_lossen; anders bouwt de combi-generator op een
  // andere set ritten dan de planner in beeld heeft.
  $dateParts = [];
  $params = [':dest' => $dest];
  $scope = tp_scope_sql($pdo, 'planned_transports', 't', $TP, $params, 'fc_');

  // 07-09-2026: dagfilter per vestiging — zelfde kolom als index_v2 in beeld heeft.
  $__cdf = function_exists('tp_company_date_field') ? tp_company_date_field($pdo, tp_scope_active_company_id($TP)) : 'datum_laden';
  if ($__cdf === 'datum_lossen' && $useDatumLossen) {
    $dateParts[] = "t.datum_lossen = :d";
    $params[':d'] = $date;
  } elseif ($useDatumLaden) {
    $dateParts[] = "t.datum_laden = :d";
    $params[':d'] = $date;
  } elseif ($useDatumLossen) {
    // Legacy-schema zonder datum_laden: val terug op datum_lossen.
    $dateParts[] = "t.datum_lossen = :d2";
    $params[':d2'] = $date;
  }

  if ($useDatum) {
    $dateParts[] = "t.datum = :d3";
    $params[':d3'] = $date;
  }

  if (!$dateParts) {
    throw new RuntimeException('planned_transports lacks date columns (datum_lossen/datum_laden/datum)');
  }

  $dateExpr = '(' . implode(' OR ', $dateParts) . ')';

  $sql = "SELECT t.id, COALESCE(t.pallets,0) AS pallets, t.losbestemming
          FROM planned_transports t
          WHERE $scope
            AND $dateExpr
            AND COALESCE(t.combo_id,0) = 0
            AND COALESCE(t.pallets,0)  > 0
            AND TRIM(t.losbestemming) = :dest";
  if ($useStatus) {
    $sql .= " AND (t.status IS NULL OR t.status <> 'vervallen')";
  }
  // Een rit die al vertrokken is kan niet meer gecombineerd worden.
  $sql .= " AND NOT " . combo_departed_sql($pdo, 't');
  $sql .= " ORDER BY t.id ASC";

  $st = $pdo->prepare($sql);
  $st->execute($params);
  return $st->fetchAll();
}
function ensure_rule_combos(PDO $pdo, string $date): array {
  global $TP;
  $groupId = tp_scope_group_id($TP);
  $companyId = tp_scope_active_company_id($TP);
  $rules = fetch_active_rules($pdo, $date, $companyId > 0 ? $companyId : null);
  $insCols = ['group_id','company_id','plan_datum','status','source','label','unload_order','total_pallets','uniq_key','created_by','created_at','transport_set_key'];
  $insVals = [':group_id',':company_id',':d',':status',':source',':label',':uo',':sum',':uniq',':created_by','NOW()',':tsk'];
  $insP = $pdo->prepare("INSERT INTO combo_proposals (" . implode(',', $insCols) . ") VALUES (" . implode(',', $insVals) . ")");
  $insI = $pdo->prepare("INSERT INTO combo_proposal_items (proposal_id, transport_id, seq, sort_order) VALUES (:pid,:tid,:seq,1)");
  $existsP = $pdo->prepare("SELECT id FROM combo_proposals WHERE uniq_key = :uniq AND company_id <=> :company_id LIMIT 1");
  $created=0; $ids=[];

  // Small helper: recursively build one candidate per stop.
  $recur = function(array $lists, int $idx, array $picked, float $sum, float $max) use (&$recur) {
    if ($sum > $max) return [];
    if ($idx >= count($lists)) return [$picked];
    $out = [];
    foreach ($lists[$idx] as $row) {
      $id = (int)$row['id'];
      // Prevent using same transport twice (in case a rule repeats a destination).
      if (in_array($id, $picked, true)) continue;
      $out = array_merge($out, $recur($lists, $idx+1, array_merge($picked, [$id]), $sum + (float)$row['pallets'], $max));
    }
    return $out;
  };

  foreach ($rules as $r){
    $stops = $r['stops'] ?? [];
    if (!is_array($stops) || count($stops) < 2) continue;
    // Practical cap: UI supports up to 4 stops today (A..D). Ignore extra stops to avoid runaway.
    $stops = array_slice(array_values($stops), 0, 4);

    $max = (float)($r['max_pallets'] ?? 33.0); if ($max <= 0) $max = 33.0;

    // Build candidates list per stop.
    $lists = [];
    foreach ($stops as $dest) {
      $cand = fetch_candidates($pdo, $date, (string)$dest);
      if (!$cand) { $lists = []; break; }
      $lists[] = $cand;
    }
    if (!$lists || count($lists) !== count($stops)) continue;

    // Generate all valid combos (one per stop) under max ground places.
    $combos = $recur($lists, 0, [], 0.0, $max);
    foreach ($combos as $pickedIds) {
      if (count($pickedIds) !== count($stops)) continue;
      $sum = 0.0;
      // Compute sum and build uniq ids (sorted for uniqueness).
      foreach ($pickedIds as $tid) {
        // Find ground places quickly by scanning the relevant list index
        $pl = 0.0;
        for ($k=0; $k<count($lists); $k++) {
          foreach ($lists[$k] as $row) { if ((int)$row['id'] === (int)$tid) { $pl = (float)$row['pallets']; break 2; } }
        }
        $sum += $pl;
      }
      if ($sum > $max) continue;

      $sorted = $pickedIds; sort($sorted, SORT_NUMERIC);
      $uniq='rule:'.$r['id'].':'.$date.':C'.(int)$companyId.':'.implode('-', $sorted);
      $tsk=md5($uniq);
      $uo = implode(' > ', $stops);
      $label='[COMBI] '.$uo;

      try {
        // Skip if this proposal already exists (keeps ensure safe to run multiple times)
        $existsP->execute([':uniq'=>$uniq, ':company_id'=>($companyId > 0 ? $companyId : null)]);
        if ($existsP->fetchColumn()) { continue; }

        $insP->execute([':group_id' => ($groupId > 0 ? $groupId : null), ':company_id' => ($companyId > 0 ? $companyId : null), ':d' => $date, ':status' => 'pending', ':source' => 'auto', ':label' => $label, ':uo' => $uo, ':sum' => $sum, ':uniq' => $uniq, ':created_by' => 'generator', ':tsk' => $tsk]);
        $pid=(int)$pdo->lastInsertId();
        for ($seq=0; $seq<count($pickedIds); $seq++) {
          $insI->execute([':pid'=>$pid, ':tid'=>(int)$pickedIds[$seq], ':seq'=>$seq+1]);
        }
        $created++; $ids[]=$pid;
      } catch (Throwable $e){ /* duplicate uniq -> skip */ }
    }
  }
  return ['created'=>$created,'proposal_ids'=>$ids];
}

// -------- Controller --------
$action = $_GET['action'] ?? 'list';

switch ($action) {
  case 'ping': {
    out(['ok'=>true,'version'=>'combos-full-guard-2025-09-01T19-45','now'=>date('c')]);
    break;
  }
  case 'list': {
    $date = toISO($_GET['date'] ?? null); if(!$date) bad('missing/invalid date',400);
    $status = $_GET['status'] ?? 'pending';
    try {
      $rows = listForDate($pdo,$date,$status);
      out(['ok'=>true,'date'=>$date,'rows'=>$rows]);
    } catch (Throwable $e) {
      out(['ok'=>false,'error'=>'list failed','msg'=>$e->getMessage(),'version'=>'combos-full-guard-2025-09-01T19-45'], 500);
    }
    break;
  }
  case 'ensure': {
    // 03-09-2026: front-end stuurt nu POST (form-encoded). GET blijft op live tijdelijk
    // toegestaan voor browsers met een oude script_v3.js in cache; na batch 2 -> alleen POST.
    $date = toISO($_POST['date'] ?? $_GET['date'] ?? null); if(!$date) bad('missing/invalid date',400);
    try {
      $meta = ensure_rule_combos($pdo,$date);
      $rows = listForDate($pdo,$date,'pending');
      out(['ok'=>true,'date'=>$date,'generator'=>$meta,'rows'=>$rows]);
    } catch (Throwable $e) {
      out(['ok'=>false,'error'=>'ensure failed','msg'=>$e->getMessage(),'version'=>'combos-full-guard-2025-09-01T19-45'], 500);
    }
    break;
  }

  case 'accept': {
    $date = toISO($_GET['date'] ?? null); // optional
    // Parse body (JSON or form)
    $raw = file_get_contents('php://input');
    $payload = json_decode($raw, true);
    $id = (int)($_POST['id'] ?? ($payload['id'] ?? ($_GET['id'] ?? 0)));
    if ($id <= 0) bad('missing id', 400);

    try {
      $pdo->beginTransaction();

      // Lock proposal
      $lockParams = [':id'=>$id];
      $proposalScope = tp_scope_sql($pdo, 'combo_proposals', '', $TP, $lockParams, 'acp_');
      $p = $pdo->prepare("SELECT * FROM combo_proposals WHERE id=:id AND $proposalScope FOR UPDATE");
      $p->execute($lockParams);
      $prop = $p->fetch();
      if (!$prop) { $pdo->rollBack(); bad('proposal not found', 404); }
      if ($prop['status'] !== 'pending') { $pdo->rollBack(); bad('proposal not pending', 409); }

      // Load and lock items + transports
      $st = $pdo->prepare("SELECT i.transport_id, i.seq, t.pallets, t.combo_id
                           FROM combo_proposal_items i
                           JOIN planned_transports t ON t.id = i.transport_id
                           WHERE i.proposal_id = :id
                           FOR UPDATE");
      $st->execute([':id'=>$id]);
      $items = $st->fetchAll();
      if (!$items || count($items) < 2) { $pdo->rollBack(); bad('proposal has insufficient items', 409); }

      // Validate: >0 ground places, not already in a combo, sum <=33
      $sum = 0; $ids=[];
      $beforeRows = [];
      foreach ($items as $it) {
        $pl = (float)($it['pallets'] ?? 0);
        $sum += $pl;
        if ($pl <= 0) { $pdo->rollBack(); bad('contains item with zero ground places', 409); }
        if ((int)$it['combo_id'] !== 0) { $pdo->rollBack(); bad('item already in a combo', 409); }
        $tid = (int)$it['transport_id'];
        $ids[] = $tid;
        try {
          $beforeRows[$tid] = tp_transport_assert_mutable($pdo, $tid, $TP, 'gewijzigd');
        } catch (TPTransportLockedException $e) {
          $pdo->rollBack();
          bad($e->getMessage(), 403);
        }
      }
      if ($sum > 33.0) { $pdo->rollBack(); bad('sum of ground places exceeds 33', 409); }

      // Mark transports as part of a combo. We use proposal id as combo_id (no FK assumed).
      if (!empty($ids)) {
        $in = implode(',', array_map('intval', $ids));
        $pdo->exec("UPDATE planned_transports SET combo_id = {$id}, updated_at = NOW() WHERE id IN ($in)");
      /* TPC: reference_number unify on accept */
      try {
        // Only if column exists
        $chk = $pdo->query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='planned_transports' AND COLUMN_NAME='reference_number'")->fetchColumn();
        if ((int)$chk > 0) {
          // Find one existing reference among items
          $ref = null;
          $rs = $pdo->query("SELECT reference_number FROM planned_transports WHERE id IN ($in) AND reference_number IS NOT NULL AND reference_number<>'' LIMIT 1");
          $ref = $rs->fetchColumn();
          if (!$ref) {
            // 08-09-2026: hier stond een geneste functie die $prop en $TP uit de
            // buitenste scope probeerde te lezen. Binnen een functie zijn die niet
            // zichtbaar, dus de vestiging viel altijd terug op 0 en werd de prefix
            // altijd VLS - ook voor Best (VLB) en Best Logistiek (VLL). Bovendien
            // gebruikte hij een prefix-helper die combos.php niet geladen had, dus
            // klapte dit blok eruit (gesmoord door de catch hieronder).
            // Nu direct de gedeelde generator, met de vestiging van het voorstel.
            $ref = tp_generate_reference_for_company(
              $pdo, 'planned_transports',
              (int)($prop['company_id'] ?? ($TP['active_company_id'] ?? 0)),
              'L'
            );
          }
          $upd = $pdo->prepare("UPDATE planned_transports SET reference_number=:r, updated_at=NOW() WHERE id IN ($in)");
          $upd->execute([':r'=>$ref]);
        }
      } catch (Throwable $e) { /* non-fatal; keep accept flow */ }

      }

/* TPC: apply rule overrides on accept */
try{
  // Load proposal to get unload_order
  $p2 = $pdo->prepare("SELECT unload_order, label FROM combo_proposals WHERE id=:id");
  $p2->execute([':id'=>$id]);
  $prow = $p2->fetch();
  $uo = (string)($prow['unload_order'] ?? $prow['label'] ?? '');
  if ($uo !== '') {
    // === TPC PATCH: proposal unload_order is leading; store as loading order (reversed) in 'laadvolgorde' ===
    $parts = preg_split('/\s*(?:>|\/)\s*/u', $uo, -1, PREG_SPLIT_NO_EMPTY);
    $parts = array_values(array_filter(array_map('trim', (array)$parts), function($s){ return $s !== ''; }));
    $loadOrder = (count($parts) > 0) ? implode(' - ', array_reverse($parts)) : $uo;
    if (!empty($ids)) {
      $in = implode(',', array_map('intval', $ids));
      $stL = $pdo->prepare("UPDATE planned_transports SET laadvolgorde = :uo, updated_at = NOW() WHERE id IN ($in)");
      $stL->execute([':uo' => $loadOrder]);
    }
    // === END PATCH ===

    // Build map of active rules for this date, keyed by the FULL stop list.
    // FIX 25-08-2026: dit stond op dest_a.' > '.dest_b, waardoor regels met
    // drie of vier stops (bv. Oosterhout > Etten Leur > Almere) hun
    // vervoerder/dock/opmerking nooit kregen. unload_order wordt bij het
    // aanmaken van het voorstel als implode(' > ', stops) opgebouwd, dus
    // dezelfde sleutel matcht hier 1-op-1 voor elke lengte.
    $acceptCompanyId = (int)($prop['company_id'] ?? 0);
    $rules = fetch_active_rules($pdo, $date ?: date('Y-m-d'), $acceptCompanyId > 0 ? $acceptCompanyId : null);
    $map = [];
    foreach($rules as $rr){
      $stopsKey = implode(' > ', array_map('trim', (array)($rr['stops'] ?? [])));
      if ($stopsKey !== '' && count((array)$rr['stops']) >= 2) {
        $map[$stopsKey] = $rr;
      }
    }
    if (isset($map[$uo])) {
      $rr = $map[$uo];
      $carrier = isset($rr['carrier_override']) ? trim((string)$rr['carrier_override']) : '';
      $dock    = isset($rr['dock_override']) ? (int)$rr['dock_override'] : 0;
      $remarks = isset($rr['remarks_template']) ? trim((string)$rr['remarks_template']) : '';
      if (!empty($ids)) {
        $in = implode(',', array_map('intval', $ids));
        $sets = [];
        $hasDockIdCol = false;
        try { $hasDockIdCol = (bool)$pdo->query("SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'planned_transports' AND column_name = 'dock_id' LIMIT 1")->fetchColumn(); } catch (Throwable $e) { $hasDockIdCol = false; }
        if ($carrier !== '') $sets[] = "vervoerder = ".$pdo->quote($carrier);
        if ($dock > 0) {
          $sets[] = "dock = ".(int)$dock;
          // dock_id afgeleid uit het nummer, per bedrijf van de rit zelf (correlated subquery op docks)
          if ($hasDockIdCol) $sets[] = "dock_id = (SELECT d.id FROM docks d WHERE d.company_id = planned_transports.company_id AND d.code = CONCAT('D', ".(int)$dock.") LIMIT 1)";
        }
        if ($remarks !== '') $sets[] = "opmerkingen = CASE WHEN opmerkingen IS NULL OR opmerkingen='' THEN ".$pdo->quote($remarks)." ELSE CONCAT(opmerkingen,'; ',".$pdo->quote($remarks).") END";
        if (!empty($sets)) {
          $sqlUpd = "UPDATE planned_transports SET ".implode(', ', $sets).", updated_at = NOW() WHERE id IN ($in)";
          $pdo->exec($sqlUpd);
        }
      }
    }
  }
}catch(Throwable $e){ /* non-fatal */ }

      /* MOMENTOPNAME (25-08-2026): waarden van vóór het koppelen bewaren,
         zodat 'release' de ritten exact kan terugzetten in plaats van te
         gokken. $beforeRows is hierboven al opgehaald vóór alle updates. */
      try {
        $snapFields = ['reference_number','laadvolgorde','vervoerder','dock','dock_id','opmerkingen'];
        $snapshot = [];
        foreach ($beforeRows as $tid => $row) {
          $vals = [];
          foreach ($snapFields as $f) if (array_key_exists($f, (array)$row)) $vals[$f] = $row[$f];
          if ($vals) $snapshot[(string)(int)$tid] = $vals;
        }
        if ($snapshot) {
          $stSnap = $pdo->prepare("UPDATE combo_proposals SET pre_combo_json = :j WHERE id = :id");
          $stSnap->execute([':j' => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ':id' => $id]);
        }
      } catch (Throwable $e) { /* kolom kan nog ontbreken vóór de migratie */ }

      // Mark proposal accepted
      $updParams = [':id'=>$id];
      $updScope = tp_scope_sql($pdo, 'combo_proposals', '', $TP, $updParams, 'acupd_');
      $st2 = $pdo->prepare("UPDATE combo_proposals SET status='accepted', decided_by='planner', decided_at=NOW() WHERE id=:id AND $updScope");
      $st2->execute($updParams);

      $pdo->commit();

      if (function_exists('planner_live_emit_event')) {
        try {
          $eventDate = toISO($date ?: ($prop['plan_datum'] ?? null)) ?: date('Y-m-d');
          planner_live_emit_event($pdo, $TP, [
            'scope' => 'transport',
            'company_id' => $prop['company_id'] ?? null,
            'group_id' => $prop['group_id'] ?? null,
            'plan_date' => $eventDate,
            'entity_type' => 'transport_combo',
            'entity_id' => (string)$id,
            'operation' => 'combo_accepted',
            'affected_ids' => $ids,
            'changed_fields' => ['combo_id','reference_number','laadvolgorde','vervoerder','dock','opmerkingen'],
            'payload' => ['proposal_id' => $id, 'transport_ids' => $ids, 'sum_pallets' => $sum, 'sum_ground_places' => $sum],
          ]);
        } catch (Throwable $e) { error_log('combo accept live event failed: ' . $e->getMessage()); }
      }

      try {
        foreach ($ids as $tid) {
          $afterRow = tp_transport_fetch_row($pdo, (int)$tid);
          if ($afterRow && isset($beforeRows[(int)$tid])) {
            tp_transport_log_update($pdo, $TP, (int)$tid, $beforeRows[(int)$tid], $afterRow, ['source' => 'combos.php?action=accept', 'combo_proposal_id' => $id]);
          }
        }
      } catch (Throwable $e) { /* non-fatal */ }

      out(['ok'=>true,'accepted'=>['id'=>$id,'sum_pallets'=>$sum,'sum_ground_places'=>$sum,'items'=>$ids]]);
    } catch (Throwable $e) {
      if ($pdo->inTransaction()) $pdo->rollBack();
      out(['ok'=>false,'error'=>'accept failed','msg'=>$e->getMessage()], 500);
    }
    break;
  }
  case 'release': {
    /* ================================================================
     * LOSMAKEN (25-08-2026) — een geaccepteerde combinatie terugbrengen
     * naar losse ritten. Altijd de HELE combinatie: ook bij drie of vier
     * lossingen gaan alle ritten terug naar individueel.
     *
     * Wat er wordt teruggedraaid, in deze volgorde:
     *   1. combo_id leeg;
     *   2. referentienummer — cruciaal, want transports.php groepeert ook
     *      op een gelijk referentienummer. Zou het gedeelde nummer blijven
     *      staan, dan blijven de ritten elkaars wijzigingen overnemen en
     *      ziet de goedkeuringsmodule ze nog steeds als één vracht. Elke
     *      rit krijgt zijn oude nummer terug; is dat onbekend, dan een
     *      nieuw nummer (op één rit na, die het gedeelde nummer houdt);
     *   3. laadvolgorde, vervoerder, dock/dock_id en opmerkingen terug naar
     *      de waarde van vóór het koppelen.
     *
     * Bron voor die oude waarden, in volgorde van betrouwbaarheid:
     *   a. pre_combo_json op het voorstel (momentopname bij koppelen);
     *   b. het audit-logboek: de accept-regel bevat per rit old/new;
     *   c. niets gevonden → veld blijft staan en wordt gemeld in
     *      'onhersteld', zodat de planner het zelf kan nalopen.
     *
     * Het voorstel krijgt status 'released' (niet 'rejected'): de
     * dagvulling koppelt een template-combinatie daardoor voor díe dag niet
     * opnieuw, terwijl de combinatie in het beheer gewoon actief blijft
     * voor andere dagen.
     * ================================================================ */
    $raw = file_get_contents('php://input');
    $payload = json_decode($raw, true);
    $id = (int)($_POST['id'] ?? ($payload['id'] ?? ($_GET['id'] ?? 0)));
    if ($id <= 0) bad('missing id', 400);

    try {
      $pdo->beginTransaction();

      $lockParams = [':id'=>$id];
      $proposalScope = tp_scope_sql($pdo, 'combo_proposals', '', $TP, $lockParams, 'relp_');
      $p = $pdo->prepare("SELECT * FROM combo_proposals WHERE id=:id AND $proposalScope FOR UPDATE");
      $p->execute($lockParams);
      $prop = $p->fetch();
      if (!$prop) { $pdo->rollBack(); bad('proposal not found', 404); }
      if ($prop['status'] !== 'accepted') { $pdo->rollBack(); bad('deze combinatie staat niet (meer) als geaccepteerd', 409); }

      // Leden: primair de ritten die dit combo_id dragen; anders de items van het voorstel.
      $st = $pdo->prepare("SELECT id FROM planned_transports WHERE combo_id = :id ORDER BY id FOR UPDATE");
      $st->execute([':id'=>$id]);
      $ids = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN) ?: []);
      if (!$ids) {
        $st2 = $pdo->prepare("SELECT transport_id FROM combo_proposal_items WHERE proposal_id = :id ORDER BY seq");
        $st2->execute([':id'=>$id]);
        $ids = array_map('intval', $st2->fetchAll(PDO::FETCH_COLUMN) ?: []);
      }
      if (!$ids) { $pdo->rollBack(); bad('geen ritten gevonden bij deze combinatie', 409); }

      // Vier-wekengrens: zelfde regel als bij accepteren.
      $beforeRows = [];
      foreach ($ids as $tid) {
        try {
          $beforeRows[$tid] = tp_transport_assert_mutable($pdo, (int)$tid, $TP, 'losgemaakt');
        } catch (TPTransportLockedException $e) {
          $pdo->rollBack();
          bad($e->getMessage(), 403);
        }
      }

      // --- oude waarden verzamelen ---------------------------------
      $snap = [];
      $rawSnap = (string)($prop['pre_combo_json'] ?? '');
      if ($rawSnap !== '') {
        $decoded = json_decode($rawSnap, true);
        if (is_array($decoded)) foreach ($decoded as $tid => $vals) if (is_array($vals)) $snap[(int)$tid] = $vals;
      }

      // Terugval op het audit-logboek voor combinaties van vóór de momentopname.
      $fields = ['reference_number','laadvolgorde','vervoerder','dock','dock_id','opmerkingen'];
      $missing = array_values(array_filter($ids, static function($tid) use ($snap){ return !isset($snap[$tid]); }));
      if ($missing) {
        try {
          $inM = implode(',', array_map('intval', $missing));
          $q = $pdo->query("SELECT entity_id, payload FROM audit_log
                             WHERE entity='planned_transports' AND entity_id IN ($inM)
                               AND payload LIKE '%combos.php?action=accept%'
                             ORDER BY id DESC");
          foreach ($q->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
            $tid = (int)$row['entity_id'];
            if (isset($snap[$tid])) continue; // nieuwste regel wint
            $pl = json_decode((string)$row['payload'], true);
            $ch = is_array($pl) ? ($pl['changes'] ?? []) : [];
            if ((int)($ch['combo_id']['new'] ?? 0) !== $id) continue;
            $vals = [];
            foreach ($fields as $f) if (array_key_exists($f, $ch)) $vals[$f] = $ch[$f]['old'];
            if ($vals) { $vals['_bron'] = 'logboek'; $snap[$tid] = $vals; }
          }
        } catch (Throwable $e) { /* logboek is optioneel */ }
      }

      // --- terugdraaien ---------------------------------------------
      $companyId = (int)($prop['company_id'] ?? ($TP['active_company_id'] ?? 0));
      $sharedRef = trim((string)($pdo->query("SELECT reference_number FROM planned_transports WHERE id = " . (int)$ids[0])->fetchColumn() ?: ''));
      $refKeeper = null;          // één rit mag het gedeelde nummer houden
      $onhersteld = [];

      $hasDockIdCol = false;
      try { $hasDockIdCol = (bool)$pdo->query("SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'planned_transports' AND column_name = 'dock_id' LIMIT 1")->fetchColumn(); } catch (Throwable $e) { $hasDockIdCol = false; }

      foreach ($ids as $tid) {
        $vals = $snap[$tid] ?? null;
        $sets = ['combo_id = NULL'];

        // referentienummer
        $oldRef = $vals !== null && array_key_exists('reference_number', $vals) ? trim((string)$vals['reference_number']) : '';
        if ($oldRef !== '' && $oldRef !== $sharedRef) {
          $sets[] = 'reference_number = ' . $pdo->quote($oldRef);
        } elseif ($refKeeper === null) {
          $refKeeper = $tid;               // deze houdt het gedeelde nummer
        } else {
          $newRef = tp_generate_reference_for_company($pdo, 'planned_transports', $companyId, 'L');
          $sets[] = 'reference_number = ' . $pdo->quote($newRef);
        }

        // laadvolgorde: bij een combinatie altijd door het koppelen gezet
        $sets[] = ($vals !== null && array_key_exists('laadvolgorde', $vals) && trim((string)$vals['laadvolgorde']) !== '')
          ? 'laadvolgorde = ' . $pdo->quote((string)$vals['laadvolgorde'])
          : 'laadvolgorde = NULL';

        // vervoerder / dock / opmerkingen: alleen terugzetten als we de oude waarde kennen
        if ($vals !== null && array_key_exists('vervoerder', $vals)) {
          $sets[] = ($vals['vervoerder'] === null || trim((string)$vals['vervoerder']) === '')
            ? 'vervoerder = NULL' : 'vervoerder = ' . $pdo->quote((string)$vals['vervoerder']);
        }
        if ($vals !== null && array_key_exists('dock', $vals)) {
          $sets[] = ($vals['dock'] === null || (string)$vals['dock'] === '') ? 'dock = NULL' : 'dock = ' . (int)$vals['dock'];
          if ($hasDockIdCol) {
            if ($vals !== null && array_key_exists('dock_id', $vals) && $vals['dock_id'] !== null && (string)$vals['dock_id'] !== '') {
              $sets[] = 'dock_id = ' . (int)$vals['dock_id'];
            } elseif ($vals['dock'] === null || (string)$vals['dock'] === '') {
              $sets[] = 'dock_id = NULL';
            }
          }
        }
        if ($vals !== null && array_key_exists('opmerkingen', $vals)) {
          $sets[] = ($vals['opmerkingen'] === null || trim((string)$vals['opmerkingen']) === '')
            ? 'opmerkingen = NULL' : 'opmerkingen = ' . $pdo->quote((string)$vals['opmerkingen']);
        }

        if ($vals === null) $onhersteld[] = (int)$tid;

        $pdo->exec("UPDATE planned_transports SET " . implode(', ', $sets) . ", updated_at = NOW() WHERE id = " . (int)$tid);
      }

      /* Vervolgstatus (25-08-2026, wens Maikel):
         - template-combinatie → 'released': de dagvulling koppelt deze dag
           niet opnieuw; het beheer blijft ongemoeid.
         - voorstel uit de motor (auto/regel/handmatig) → terug naar 'pending':
           de combinatie verschijnt direct weer in de voorstellenlijst en kan
           later opnieuw geaccepteerd worden. released_at/_by blijven als
           spoor staan; bij een nieuwe acceptatie wordt de momentopname
           gewoon opnieuw geschreven. */
      $relParams = [':id'=>$id, ':by'=>mb_substr(trim((string)($TP['planner']['naam'] ?? $TP['planner']['name'] ?? 'planner')), 0, 80)];
      $relScope = tp_scope_sql($pdo, 'combo_proposals', '', $TP, $relParams, 'relu_');
      $isTemplate = ((string)($prop['source'] ?? '') === 'template');
      $sqlRel = $isTemplate
        ? "UPDATE combo_proposals SET status='released', released_at=NOW(), released_by=:by WHERE id=:id AND $relScope"
        : "UPDATE combo_proposals SET status='pending', decided_by=NULL, decided_at=NULL, released_at=NOW(), released_by=:by WHERE id=:id AND $relScope";
      $pdo->prepare($sqlRel)->execute($relParams);

      $pdo->commit();

      if (function_exists('planner_live_emit_event')) {
        try {
          planner_live_emit_event($pdo, $TP, [
            'scope' => 'transport',
            'company_id' => $prop['company_id'] ?? null,
            'group_id' => $prop['group_id'] ?? null,
            'plan_date' => toISO($prop['plan_datum'] ?? null) ?: date('Y-m-d'),
            'entity_type' => 'transport_combo',
            'entity_id' => (string)$id,
            'operation' => 'combo_released',
            'affected_ids' => $ids,
            'changed_fields' => ['combo_id','reference_number','laadvolgorde','vervoerder','dock','opmerkingen'],
            'payload' => ['proposal_id' => $id, 'transport_ids' => $ids],
          ]);
        } catch (Throwable $e) { error_log('combo release live event failed: ' . $e->getMessage()); }
      }

      try {
        foreach ($ids as $tid) {
          $afterRow = tp_transport_fetch_row($pdo, (int)$tid);
          if ($afterRow && isset($beforeRows[(int)$tid])) {
            tp_transport_log_update($pdo, $TP, (int)$tid, $beforeRows[(int)$tid], $afterRow, ['source' => 'combos.php?action=release', 'combo_proposal_id' => $id]);
          }
        }
      } catch (Throwable $e) { /* non-fatal */ }

      out(['ok'=>true,'released'=>['id'=>$id,'items'=>$ids,'onhersteld'=>$onhersteld,'proposal_status'=>($isTemplate?'released':'pending')]]);
    } catch (Throwable $e) {
      if ($pdo->inTransaction()) $pdo->rollBack();
      out(['ok'=>false,'error'=>'release failed','msg'=>$e->getMessage()], 500);
    }
    break;
  }
  case 'reject': {
    // Reject a pending proposal (does not touch transports)
    $raw = file_get_contents('php://input');
    $payload = json_decode($raw, true);
    $id = (int)($_POST['id'] ?? ($payload['id'] ?? ($_GET['id'] ?? 0)));
    if ($id <= 0) bad('missing id', 400);
    try {
      $rejParams = [':id'=>$id];
      $rejScope = tp_scope_sql($pdo, 'combo_proposals', '', $TP, $rejParams, 'rej_');
      $proposalBefore = null;
      try {
        $sel = $pdo->prepare("SELECT id, group_id, company_id, plan_datum FROM combo_proposals WHERE id=:id AND $rejScope LIMIT 1");
        $sel->execute($rejParams);
        $proposalBefore = $sel->fetch(PDO::FETCH_ASSOC) ?: null;
      } catch (Throwable $e) { $proposalBefore = null; }
      $st = $pdo->prepare("UPDATE combo_proposals SET status='rejected', decided_by='planner', decided_at=NOW() WHERE id=:id AND status='pending' AND $rejScope");
      $st->execute($rejParams);
      if ((int)$st->rowCount() > 0 && function_exists('planner_live_emit_event')) {
        try {
          planner_live_emit_event($pdo, $TP, [
            'scope' => 'transport',
            'company_id' => $proposalBefore['company_id'] ?? null,
            'group_id' => $proposalBefore['group_id'] ?? null,
            'plan_date' => toISO($proposalBefore['plan_datum'] ?? null) ?: date('Y-m-d'),
            'entity_type' => 'transport_combo',
            'entity_id' => (string)$id,
            'operation' => 'combo_rejected',
            'affected_ids' => [$id],
            'changed_fields' => ['status'],
            'payload' => ['proposal_id' => $id],
          ]);
        } catch (Throwable $e) { error_log('combo reject live event failed: ' . $e->getMessage()); }
      }
      out(['ok'=>true,'rejected'=>['id'=>$id,'affected'=>$st->rowCount()]]);
    } catch (Throwable $e) {
      out(['ok'=>false,'error'=>'reject failed','msg'=>$e->getMessage()], 500);
    }
    break;
  }

  default: {
    bad('unknown action',404);
  }
}
