<?php
/**
 * cnc_scheduler_lib.php
 * =====================================================================
 * SINGLE SOURCE OF TRUTH for all CNC scheduling logic.
 *
 * WHY THIS FILE EXISTS
 * ---------------------------------------------------------------------
 * The previous codebase defined recalculateMachineDates(),
 * recalculateMachineDates_fororiginal(), processMachineScheduling(),
 * getShiftsForDate(), computeAvailableHoursBetween(),
 * advanceTimestampByWorkHours(), calculateAlternatingSchedule(), etc.
 * THREE separate times (in the Gantt chart include, in
 * ajax_actions_for_cnc.php, and in ajax_actions_cnc.php) and the three
 * copies had already drifted apart:
 *   - one used setup_hours+breakdown_hours as the overlap offset,
 *     the other two used setup_hours alone
 *   - one used an additive overlap-resolution formula, the other two
 *     used a cycle-time-based alternation formula
 *   - one filtered getCurrentOrders() by finished=0, the other two
 *     didn't
 * Because all three declared the same function names at global scope,
 * this was also one accidental double-include away from a fatal
 * "Cannot redeclare function" error.
 *
 * Include this file ONCE per request, from every endpoint file.
 *
 * BUG FIXES APPLIED HERE (see chat writeup for full detail):
 *   1. cnc_part_count_log.cnc_machine_table_id stores manuf_id, not the
 *      real cnc_machine_table.id. Every lookup here uses manuf_id.
 *   2. getCurrentOrders() now always filters finished = 0.
 *   3. Overlap offset consistently uses setup_hours + breakdown_hours.
 *   4. calculateAlternatingSchedule()'s "no overlap" branch now keeps
 *      each order's own original_total_hours instead of recomputing
 *      total_hours from raw available shift-hours in the window (that
 *      was inflating total_hours with unrelated slack).
 *   5. scheduleNewTaskInline() now actually accepts and uses a
 *      $startFrom override (previously accepted-but-silently-ignored
 *      via a PHP extra-argument no-op in the dual-table insert path).
 *   6. recalculateMachineDates() and recalculateMachineDates_fororiginal()
 *      are now one parameterized function (they were 95% identical).
 *   7. Dual-table mirror-break creation is now a single function,
 *      callable both at insert time and after a drag/drop, that
 *      always re-fetches fresh timing before building the mirror.
 *   8. splitCncTask() is now one function used by both the manual
 *      "Split Work Order" action and the breakdown partial-completion
 *      split (previously duplicated with slightly different field
 *      lists, and the breakdown copy never propagated is_dual_table).
 *
 * FIELDS DROPPED FROM ACTIVE USE (see CHANGELOG.md):
 *   dual_table_break_id, linked_task_id, is_shadow_break, shadow_of
 *   -> unreferenced anywhere in the 4 source files; superseded by
 *      parent_task_id + is_break + is_dual_table. Not referenced by
 *      any query in this rewrite. Recommend confirming no other module
 *      reads them before an ALTER TABLE DROP COLUMN.
 * =====================================================================
 */

if (defined('CNC_SCHEDULER_LIB_LOADED')) {
    return; // hard guard against double-include even if someone includes twice
}
define('CNC_SCHEDULER_LIB_LOADED', true);

/* =====================================================================
 * SHIFT / CALENDAR PRIMITIVES
 * ===================================================================== */

/**
 * Load all working_hours rows grouped by weekday number (0=Sunday..6=Saturday,
 * matching PHP's date('w')).
 */
function cnc_getShiftsByDay($conn) {
    $shiftsByDay = [];
    $res = mysqli_query($conn, "SELECT * FROM working_hours");
    while ($row = mysqli_fetch_assoc($res)) {
        $day = (int)$row['day_shifts'];
        $shiftsByDay[$day][] = $row;
    }
    return $shiftsByDay;
}

/**
 * Shifts for one specific calendar date, as [start_ts, end_ts, shift_name].
 * Overnight shifts (end_time <= start_time) roll into the next calendar day.
 * Uses date('w') (0=Sun) consistently — the old files disagreed between
 * date('w') and date('N') in different copies of getShiftsForDate(); date('w')
 * is what recalculateMachineDates() / scheduleNewTaskInline() key off of, so
 * that is the canonical convention here.
 */
function cnc_getShiftsForDate($date, $conn, $shiftsByDay = null) {
    $weekdayNum = (int) date('w', strtotime($date));
    $shifts = [];

    if ($shiftsByDay !== null) {
        $dayRows = $shiftsByDay[$weekdayNum] ?? [];
    } else {
        $sql = "SELECT shift_name, start_time, end_time FROM working_hours WHERE day_shifts = " . intval($weekdayNum);
        $res = mysqli_query($conn, $sql);
        $dayRows = [];
        while ($row = mysqli_fetch_assoc($res)) $dayRows[] = $row;
    }

    foreach ($dayRows as $row) {
        $start_ts = strtotime($date . ' ' . $row['start_time']);
        $end_ts   = strtotime($date . ' ' . $row['end_time']);
        if ($end_ts <= $start_ts) {
            $end_ts = strtotime('+1 day', $end_ts); // overnight shift
        }
        $shifts[] = [
            'shift_name' => $row['shift_name'],
            'start_ts'   => $start_ts,
            'end_ts'     => $end_ts,
        ];
    }
    return $shifts;
}

/** Total shift-hours available strictly between two timestamps. */
function cnc_computeAvailableHoursBetween($startTs, $endTs, $conn, $shiftsByDay = null) {
    if ($endTs <= $startTs) return 0.0;

    $totalHours = 0.0;
    $current = $startTs;

    while ($current < $endTs) {
        $currentDate = date('Y-m-d', $current);
        $nextDay     = strtotime('tomorrow', $current);
        $periodEnd   = min($nextDay, $endTs);

        $shifts = cnc_getShiftsForDate($currentDate, $conn, $shiftsByDay);
        foreach ($shifts as $shift) {
            $segStart = max($current, $shift['start_ts']);
            $segEnd   = min($periodEnd, $shift['end_ts']);
            if ($segEnd > $segStart) {
                $totalHours += ($segEnd - $segStart) / 3600.0;
            }
        }
        $current = $nextDay;
    }
    return round($totalHours, 6);
}

/** Advance from $startTs through shifts until $hoursNeeded of shift-time is consumed. */
function cnc_advanceTimestampByWorkHours($startTs, $hoursNeeded, $conn, $shiftsByDay = null) {
    if ($hoursNeeded <= 0) return $startTs;

    $remainingSeconds = $hoursNeeded * 3600.0;
    $current  = $startTs;
    $maxDays  = 3650;
    $looped   = 0;

    while ($remainingSeconds > 0 && $looped < $maxDays) {
        $currentDate = date('Y-m-d', $current);
        $nextDay     = strtotime('tomorrow', $current);
        $shifts      = cnc_getShiftsForDate($currentDate, $conn, $shiftsByDay);

        foreach ($shifts as $shift) {
            if ($current >= $shift['end_ts']) continue;
            $segStart = max($current, $shift['start_ts']);
            $available = $shift['end_ts'] - $segStart;
            if ($available <= 0) continue;

            if ($available >= $remainingSeconds) {
                return (int) ($segStart + $remainingSeconds);
            }
            $remainingSeconds -= $available;
            $current = $shift['end_ts'];
        }
        $current = $nextDay;
        $looped++;
    }
    return (int) $current;
}

/**
 * Walk shifts from $startFrom (or "today 00:00" if not given) allocating
 * $hoursToSchedule hours of work. Returns start/end timestamps as strings,
 * the first shift used, and a per-date hours breakdown string.
 *
 * FIX: previously called with a 6th $alignedStart argument from the
 * dual-table insert path, which PHP silently drops for a 5-parameter
 * function — the alignment computed just before the call was therefore
 * never actually applied. $startFrom now does what that call site
 * always intended.
 */
function cnc_scheduleNewTaskInline($conn, $hoursToSchedule, $shiftsByDay, $startFrom = null) {
    $currentDateTime = $startFrom !== null
        ? strtotime($startFrom)
        : strtotime(date('Y-m-d') . ' 00:00:00');

    $dateHoursMap   = [];
    $taskStartTime  = null;
    $taskEndTime    = null;
    $firstShiftUsed = null;
    $hoursRemaining = $hoursToSchedule;

    // If hoursToSchedule is 0 (e.g. a fully-consumed setup with no production
    // left), still produce a zero-length but valid start=end pair rather than
    // looping forever or returning nulls.
    if ($hoursRemaining <= 0) {
        return [
            'start_date'      => date('Y-m-d H:i:s', $currentDateTime),
            'end_date'        => date('Y-m-d H:i:s', $currentDateTime),
            'shift_ids'       => null,
            'remaining_hours' => '',
        ];
    }

    while ($hoursRemaining > 0) {
        $currentDate = date('Y-m-d', $currentDateTime);
        $dayOfWeek   = (int) date('w', $currentDateTime);

        if (!isset($shiftsByDay[$dayOfWeek])) {
            $currentDateTime = strtotime($currentDate . ' +1 day');
            continue;
        }

        foreach ($shiftsByDay[$dayOfWeek] as $shift) {
            $shiftStart = strtotime($currentDate . ' ' . $shift['start_time']);
            $shiftEnd   = strtotime($currentDate . ' ' . $shift['end_time']);
            if ($shiftEnd <= $shiftStart) $shiftEnd = strtotime('+1 day', $shiftEnd);
            $shiftName  = $shift['shift_name'];

            if ($shiftEnd <= $currentDateTime) continue;

            $actualStart = max($currentDateTime, $shiftStart);
            $availableHours = max(0, $shiftEnd - $actualStart) / 3600;
            if ($availableHours <= 0) continue;

            $allocatable = min($hoursRemaining, $availableHours);

            if (!$taskStartTime) {
                $taskStartTime  = $actualStart;
                $firstShiftUsed = $shiftName;
            }

            $taskEndTime = $actualStart + ($allocatable * 3600);
            $hoursRemaining -= $allocatable;

            $dateOnly = date('Y-m-d', $actualStart);
            $dateHoursMap[$dateOnly] = ($dateHoursMap[$dateOnly] ?? 0) + $allocatable;

            $currentDateTime = $taskEndTime;
            if ($hoursRemaining <= 0) break;
        }

        if ($hoursRemaining > 0) {
            $currentDateTime = strtotime(date('Y-m-d', $currentDateTime) . ' +1 day 00:00:00');
        }
    }

    $remainingHoursStr = '';
    foreach ($dateHoursMap as $date => $hrs) {
        $remainingHoursStr .= $date . '=' . round($hrs, 2) . ',';
    }
    $remainingHoursStr = rtrim($remainingHoursStr, ',');

    return [
        'start_date'      => date('Y-m-d H:i:s', $taskStartTime),
        'end_date'        => date('Y-m-d H:i:s', $taskEndTime),
        'shift_ids'       => $firstShiftUsed,
        'remaining_hours' => $remainingHoursStr,
    ];
}

/* =====================================================================
 * PRIORITY-ORDER RESCHEDULE (single parameterized function replaces
 * recalculateMachineDates() + recalculateMachineDates_fororiginal())
 * ===================================================================== */

/**
 * Re-walk every unfinished row for a machine+table, in priority order,
 * writing either the "live" columns or the "original" columns.
 *
 * @param bool $original  false = write start_date/end_date/remaing_hours
 *                          (source column: total_hours)
 *                         true  = write original_start_date/original_end_date/
 *                          original_remaing_hours
 *                          (source column: original_total_hours)
 */
function cnc_recalculateMachineDates($conn, $machineId, $tableId, $original = false) {
    set_time_limit(300);

    $shiftsByDay = cnc_getShiftsByDay($conn);
    $hoursCol    = $original ? 'original_total_hours' : 'total_hours';

    $machineId = intval($machineId);
    $tableId   = intval($tableId);

    $taskResult = mysqli_query($conn, "
        SELECT id, `$hoursCol` AS hours_to_schedule
        FROM cnc_machine_table
        WHERE machine_id = $machineId
          AND table_id   = $tableId
          AND finished   = 0
        ORDER BY priority
    ");

    $tasks = [];
    while ($row = mysqli_fetch_assoc($taskResult)) $tasks[] = $row;

    $shiftsByDay_local = $shiftsByDay;
    $cursor = null; // null => cnc_scheduleNewTaskInline defaults to today 00:00

    foreach ($tasks as $task) {
        $hours = floatval($task['hours_to_schedule']);
        $sched = cnc_scheduleNewTaskInline($conn, $hours, $shiftsByDay_local, $cursor);

        $startStr = $sched['start_date'];
        $endStr   = $sched['end_date'];
        $shiftId  = mysqli_real_escape_string($conn, (string)$sched['shift_ids']);
        $remStr   = mysqli_real_escape_string($conn, $sched['remaining_hours']);

        if ($original) {
            $query = "UPDATE cnc_machine_table SET
                original_start_date    = '$startStr',
                original_end_date      = '$endStr',
                shift_ids               = '$shiftId',
                original_remaing_hours = '$remStr'
                WHERE id = {$task['id']}";
        } else {
            $query = "UPDATE cnc_machine_table SET
                start_date    = '$startStr',
                end_date      = '$endStr',
                shift_ids     = '$shiftId',
                remaing_hours = '$remStr'
                WHERE id = {$task['id']}";
        }
        mysqli_query($conn, $query);

        // Next task in priority order continues from where this one ended —
        // matches original behaviour of a persistent $currentDateTime pointer.
        $cursor = $endStr;
    }
}

/* =====================================================================
 * OVERLAP DETECTION AND RESOLUTION
 * ===================================================================== */

$GLOBALS['__cnc_updatedOrders'] = [];

/**
 * Orders currently scheduled on a machine (original/priority schedule),
 * used as the input to overlap detection.
 * FIX: always filters finished = 0 (one of the three old copies dropped this).
 */
function cnc_getCurrentOrders($machineId, $conn) {
    $machineId = intval($machineId);
    // FIX: is_dual_table = 0 excludes dual-table main tasks from overlap
    // detection entirely. The alternating-cycle throttling this feeds
    // (cnc_calculateAlternatingSchedule) is a rule for two INDEPENDENT
    // jobs that happen to run on table1/table2 concurrently -- it was
    // never meant to apply to a dual-table job, whose "concurrency" with
    // itself across both tables is already fully accounted for by its
    // mirror reservation. Relying on "the main task's window and its
    // mirror's window always land on the exact same timestamp" was
    // fragile: they're computed by two independent per-table shift-walks,
    // and any rounding/boundary mismatch between them could make this
    // engine think the dual job genuinely clashes with an adjacent real
    // job and start adding hours to it that were never warranted.
    // Excluding dual main tasks here removes that possibility completely
    // rather than depending on the two walks staying in perfect sync.
    $sql = "SELECT *,
                   original_start_date  AS start_date,
                   original_end_date    AS end_date,
                   original_total_hours AS total_hours
            FROM cnc_machine_table
            WHERE machine_id  = $machineId
              AND is_break    = 0
              AND is_dual_table = 0
              AND finished    = 0
            ORDER BY priority ASC, original_start_date ASC";
    $result = mysqli_query($conn, $sql);
    $orders = [];
    while ($row = mysqli_fetch_assoc($result)) {
        if (isset($GLOBALS['__cnc_updatedOrders'][$row['id']]['end_date'])) {
            $u = $GLOBALS['__cnc_updatedOrders'][$row['id']];
            $row['end_date']      = $u['end_date'];
            $row['total_hours']   = $u['total_hours'];
            $row['remaing_hours'] = $u['remaing_hours'];
        }
        $orders[] = $row;
    }
    return $orders;
}

function cnc_fetchOrder($orderId, $conn) {
    $orderId = intval($orderId);
    $sql = "SELECT *,
                   original_start_date  AS start_date,
                   original_end_date    AS end_date,
                   original_total_hours AS total_hours
            FROM cnc_machine_table WHERE id = $orderId LIMIT 1";
    $result = mysqli_query($conn, $sql);
    $row = mysqli_fetch_assoc($result);
    if ($row && isset($GLOBALS['__cnc_updatedOrders'][$row['id']]['end_date'])) {
        $u = $GLOBALS['__cnc_updatedOrders'][$row['id']];
        $row['end_date']      = $u['end_date'];
        $row['total_hours']   = $u['total_hours'];
        $row['remaing_hours'] = $u['remaing_hours'];
    }
    return $row;
}

/** setup+breakdown offset used consistently everywhere (fix #3). */
function cnc_offsetHours($order) {
    return (float)($order['setup_hours'] ?? 0) + (float)($order['breakdown_hours'] ?? 0);
}

function cnc_findPotentialOverlaps($orders) {
    $overlaps = [];
    $n = count($orders);
    for ($i = 0; $i < $n - 1; $i++) {
        for ($j = $i + 1; $j < $n; $j++) {
            $o1 = $orders[$i];
            $o2 = $orders[$j];
            $start1 = strtotime($o1['start_date']) + (cnc_offsetHours($o1) * 3600);
            $end1   = strtotime($o1['end_date']);
            $start2 = strtotime($o2['start_date']) + (cnc_offsetHours($o2) * 3600);
            $end2   = strtotime($o2['end_date']);
            if (($start1 < $end2) && ($start2 < $end1)) {
                $overlaps[] = [$o1, $o2];
            }
        }
    }
    return $overlaps;
}

/**
 * Cycle-time-based alternation (chosen as the single canonical algorithm —
 * more granular than the additive formula, and was already the version used
 * by 2 of the 3 old copies).
 *
 * FIX (#4): the "no overlap" branch previously recomputed total_hours as
 * "all raw shift-hours between start and end", which silently inflated a
 * job's hours with unrelated slack time. It now just keeps the job's own
 * original_total_hours, unchanged.
 */
function cnc_calculateAlternatingSchedule($order1, $order2, $conn, $shiftsByDay = null) {
    $setup1 = cnc_offsetHours($order1);
    $setup2 = cnc_offsetHours($order2);

    $start1 = strtotime($order1['start_date']) + ($setup1 * 3600);
    $end1   = strtotime($order1['end_date']);
    $start2 = strtotime($order2['start_date']) + ($setup2 * 3600);
    $end2   = strtotime($order2['end_date']);

    $overlapStart = max($start1, $start2);
    $overlapEnd   = min($end1, $end2);

    if ($overlapStart >= $overlapEnd) {
        // No real overlap: leave each order's hours untouched.
        return [
            'order1' => [
                'end_date'      => date('Y-m-d H:i:s', $end1),
                'total_hours'   => (float)$order1['original_total_hours'],
                'remaing_hours' => cnc_generateRemainingHours($order1, $end1, $conn, $shiftsByDay),
            ],
            'order2' => [
                'end_date'      => date('Y-m-d H:i:s', $end2),
                'total_hours'   => (float)$order2['original_total_hours'],
                'remaing_hours' => cnc_generateRemainingHours($order2, $end2, $conn, $shiftsByDay),
            ],
        ];
    }

    $overlapAvailableHours = cnc_computeAvailableHoursBetween($overlapStart, $overlapEnd, $conn, $shiftsByDay);

    $cycle1 = max(0.0001, (float)$order1['cycle']); // minutes
    $cycle2 = max(0.0001, (float)$order2['cycle']);

    $totalCycles1 = floor(($overlapAvailableHours * 60) / $cycle1);
    $totalCycles2 = floor(($overlapAvailableHours * 60) / $cycle2);

    $alternatingCycles = min($totalCycles1, $totalCycles2);
    $alternatingTime   = ($alternatingCycles * $cycle1 + $alternatingCycles * $cycle2) / 60.0;

    $remainingCycles1 = max(0, $totalCycles1 - $alternatingCycles);
    $remainingCycles2 = max(0, $totalCycles2 - $alternatingCycles);
    $remainingTime1   = ($remainingCycles1 * $cycle1) / 60.0;
    $remainingTime2   = ($remainingCycles2 * $cycle2) / 60.0;

    $origStart1 = strtotime($order1['start_date']);
    $origStart2 = strtotime($order2['start_date']);

    $prodStart1 = $origStart1 + ($setup1 * 3600);
    $prodStart2 = $origStart2 + ($setup2 * 3600);

    $prodHours1 = cnc_computeAvailableHoursBetween($prodStart1, $end1, $conn, $shiftsByDay);
    $prodHours2 = cnc_computeAvailableHoursBetween($prodStart2, $end2, $conn, $shiftsByDay);

    $nonOverlap1 = max(0, $prodHours1 - $overlapAvailableHours);
    $nonOverlap2 = max(0, $prodHours2 - $overlapAvailableHours);

    $newTotal1 = $setup1 + $nonOverlap1 + $alternatingTime + $remainingTime1;
    $newTotal2 = $setup2 + $nonOverlap2 + $alternatingTime + $remainingTime2;

    $newEnd1 = cnc_advanceTimestampByWorkHours($origStart1, $newTotal1, $conn, $shiftsByDay);
    $newEnd2 = cnc_advanceTimestampByWorkHours($origStart2, $newTotal2, $conn, $shiftsByDay);

    return [
        'order1' => [
            'end_date'      => date('Y-m-d H:i:s', $newEnd1),
            'total_hours'   => $newTotal1,
            'remaing_hours' => cnc_generateRemainingHours($order1, $newEnd1, $conn, $shiftsByDay),
        ],
        'order2' => [
            'end_date'      => date('Y-m-d H:i:s', $newEnd2),
            'total_hours'   => $newTotal2,
            'remaing_hours' => cnc_generateRemainingHours($order2, $newEnd2, $conn, $shiftsByDay),
        ],
    ];
}

function cnc_generateRemainingHours($order, $newEndTimestamp, $conn, $shiftsByDay = null) {
    $setupSecondsRemaining = cnc_offsetHours($order) * 3600;
    $start = strtotime($order['start_date']);
    $end   = $newEndTimestamp;
    $remainingHours = [];
    $current = $start;

    while ($current < $end) {
        $nextDay   = strtotime('tomorrow', $current);
        $periodEnd = min($nextDay, $end);
        $dateAvailable = 0.0;

        $shifts = cnc_getShiftsForDate(date('Y-m-d', $current), $conn, $shiftsByDay);
        foreach ($shifts as $shift) {
            $segStart = max($current, $shift['start_ts']);
            $segEnd   = min($periodEnd, $shift['end_ts']);
            if ($segEnd > $segStart) {
                $segHours = ($segEnd - $segStart) / 3600.0;
                if ($setupSecondsRemaining > 0) {
                    $deduct = min($segHours, $setupSecondsRemaining / 3600);
                    $segHours -= $deduct;
                    $setupSecondsRemaining -= $deduct * 3600;
                }
                $dateAvailable += $segHours;
            }
        }
        if ($dateAvailable > 0) {
            $remainingHours[date('Y-m-d', $current)] = round($dateAvailable, 2);
        }
        $current = $nextDay;
    }

    $formatted = [];
    foreach ($remainingHours as $date => $hours) {
        if ($hours > 0) $formatted[] = "$date=$hours";
    }
    return implode(',', $formatted);
}

function cnc_storeUpdatedSchedule($orderId, $data) {
    $GLOBALS['__cnc_updatedOrders'][$orderId] = $data;
}

function cnc_updateOrderHours($orderId, $data, $conn) {
    $orderId       = intval($orderId);
    $total_hours   = (float) $data['total_hours'];
    $remaing_hours = mysqli_real_escape_string($conn, $data['remaing_hours']);
    mysqli_query($conn, "
        UPDATE cnc_machine_table
        SET total_hours = $total_hours, remaing_hours = '$remaing_hours'
        WHERE id = $orderId
    ");
}

/**
 * Full overlap-detect-and-resolve pass for one machine (both tables at once,
 * since dual-table jobs span both).
 */
function cnc_processMachineScheduling($machineId, $conn) {
    $machineId   = intval($machineId);
    $shiftsByDay = cnc_getShiftsByDay($conn);
    $orders = cnc_getCurrentOrders($machineId, $conn);

    foreach ($orders as $order) {
        $orderId = $order['id'];
        $originalHours = (float) $order['original_total_hours'];
        $remainingHours = cnc_generateRemainingHours($order, strtotime($order['end_date']), $conn, $shiftsByDay);

        cnc_storeUpdatedSchedule($orderId, [
            'end_date'      => $order['end_date'],
            'total_hours'   => $originalHours,
            'remaing_hours' => $remainingHours,
        ]);

        mysqli_query($conn, "
            UPDATE cnc_machine_table
            SET total_hours = $originalHours, remaing_hours = '" . mysqli_real_escape_string($conn, $remainingHours) . "'
            WHERE id = $orderId
        ");
    }

    if (count($orders) < 2) return;

    $overlaps = cnc_findPotentialOverlaps($orders);
    if (empty($overlaps)) return;

    foreach ($overlaps as [$o1, $o2]) {
        $order1 = cnc_fetchOrder($o1['id'], $conn);
        $order2 = cnc_fetchOrder($o2['id'], $conn);
        if (!$order1 || !$order2) continue;

        $alt = cnc_calculateAlternatingSchedule($order1, $order2, $conn, $shiftsByDay);

        cnc_storeUpdatedSchedule($order1['id'], $alt['order1']);
        cnc_storeUpdatedSchedule($order2['id'], $alt['order2']);

        cnc_updateOrderHours($order1['id'], $alt['order1'], $conn);
        cnc_updateOrderHours($order2['id'], $alt['order2'], $conn);
    }
}

/**
 * Runs the full reschedule chain that every mutation needs. This is now
 * the ONE place dual-table consistency is enforced, for every possible
 * cause of it going stale:
 *
 *   1. Position-only pass (no overlap resolution yet).
 *   2. cnc_resyncAllDualTableMirrorsOnMachine() — rebuilds every
 *      dual-table job's filler/mirror from CURRENT backlog. This is not
 *      limited to dual jobs that were directly touched by whatever
 *      triggered this reschedule: dragging, deleting, or inserting an
 *      ORDINARY (non-dual) task before/after an EXISTING dual job changes
 *      that dual job's "what precedes it" backlog just as much as moving
 *      the dual job itself would — and the same is true on whichever
 *      table the dual job's MIRROR sits on. Rather than trying to detect
 *      every such case at each call site (drag, delete, insert, split,
 *      breakdown, daily batch...) and inevitably missing one, every dual
 *      job on the machine is unconditionally resynced here, every time.
 *      Cheap when there are no dual jobs on the machine (one query,
 *      zero-iteration loop); correctness is worth the small cost when
 *      there are.
 *   3. Overlap resolution — now safe, since every dual job's mirror
 *      already exists and protects its opposite table before this runs.
 *      Dual-table main tasks are excluded from this step entirely (see
 *      cnc_getCurrentOrders()) rather than merely expected to never
 *      overlap by construction — two independent per-table shift-walks
 *      landing on the exact same timestamp isn't something to rely on
 *      across shift boundaries and rounding, so this closes the
 *      possibility outright instead of hoping the timing lines up.
 *   3.5. cnc_forceDualTableOwnHours() — because dual main tasks are now
 *      excluded from step 3, they no longer get their total_hours reset
 *      to original_total_hours by that step's usual reset loop. This
 *      does that reset unconditionally, directly — a dual main task's
 *      total_hours is ALWAYS exactly its original_total_hours, full
 *      stop, never touched by overlap logic at all.
 *   4. Live schedule recalculation.
 *   5. Hours-sync safety net (e.g. after a breakdown adjustment changes a
 *      main task's hours without anything else telling its mirror).
 *
 * Centralised so call sites can't forget a step, get the order wrong, or
 * forget that a seemingly-unrelated mutation can still disturb an
 * existing dual-table job elsewhere on the machine.
 */
function cnc_fullRescheduleMachine($conn, $machineId) {
    cnc_recalculateMachineDates($conn, $machineId, 1, true);
    cnc_recalculateMachineDates($conn, $machineId, 2, true);

    cnc_resyncAllDualTableMirrorsOnMachine($conn, $machineId);

    cnc_recalculateMachineDates($conn, $machineId, 1, true);
    cnc_recalculateMachineDates($conn, $machineId, 2, true);
    cnc_processMachineScheduling($machineId, $conn);
    cnc_forceDualTableOwnHours($conn, $machineId);
    cnc_recalculateMachineDates($conn, $machineId, 2, false);
    cnc_recalculateMachineDates($conn, $machineId, 1, false);

    if (cnc_syncDualTableMirrors($conn, $machineId)) {
        cnc_recalculateMachineDates($conn, $machineId, 1, true);
        cnc_recalculateMachineDates($conn, $machineId, 2, true);
        cnc_processMachineScheduling($machineId, $conn);
        cnc_forceDualTableOwnHours($conn, $machineId);
        cnc_recalculateMachineDates($conn, $machineId, 2, false);
        cnc_recalculateMachineDates($conn, $machineId, 1, false);
    }
}

/**
 * Unconditionally forces every dual-table main task's total_hours back to
 * its original_total_hours. Needed because cnc_getCurrentOrders() now
 * excludes dual main tasks from overlap resolution (and therefore from
 * that mechanism's own total_hours reset) — this is the direct
 * replacement for that reset, scoped only to dual tasks, with no overlap
 * logic involved at all. A dual task's own duration is never anything
 * other than its clean baseline.
 */
function cnc_forceDualTableOwnHours($conn, $machineId) {
    $machineId = intval($machineId);
    mysqli_query($conn, "
        UPDATE cnc_machine_table
        SET total_hours = original_total_hours
        WHERE machine_id = $machineId AND is_dual_table = 1 AND is_break = 0 AND finished = 0
    ");
}

/* =====================================================================
 * DUAL-TABLE HELPERS
 * ---------------------------------------------------------------------
 * REWRITTEN. The previous version compared each table's "last end date"
 * AFTER the main task had already been placed/moved onto one of them,
 * and inserted the alignment filler using MAX(priority)+1 on whichever
 * table needed it while inserting the mirror using the MAIN TASK'S OWN
 * priority number reused on the OPPOSITE table's independent priority
 * sequence. Both of those caused real, reported problems:
 *
 *   - On an empty machine, by the time the comparison ran, the main
 *     task's own table looked "busy" (it had just been scheduled) while
 *     the opposite table still looked genuinely empty — a fake gap was
 *     detected and filled with a break that duplicated exactly what the
 *     mirror was about to reserve anyway.
 *
 *   - On drag/drop, reusing the main task's priority number as an
 *     insertion index into the OPPOSITE table's own, unrelated priority
 *     sequence had no real meaning — it could land the mirror in the
 *     middle of the opposite table's existing real queue, shuffling
 *     jobs that had nothing to do with the dragged task.
 *
 * The corrected model is symmetric and computed BEFORE any placement:
 *   1. Find each table's true backlog end, excluding the task itself
 *      (and after any of its own old filler/mirror rows are deleted).
 *   2. commonStart = the later of the two.
 *   3. Whichever table is AHEAD of commonStart gets a filler that closes
 *      exactly that gap, inserted at the correct spot for THAT table:
 *        - on the main task's own table: at the task's own priority
 *          (pushing the task itself back to start right after the filler)
 *        - on the opposite table: appended after its own real queue
 *          (MAX(priority)+1 on THAT table, never borrowed from the other)
 *   4. The opposite-table reservation is inserted with the SAME
 *      total_hours as the main task (not a manually computed start/end),
 *      so the normal per-table shift-walk derives an identical wall-clock
 *      window on both tables — no separate date arithmetic to drift.
 * ===================================================================== */

function cnc_shouldOccupyBothTables($conn, $partId, $setId = 0) {
    if ($setId > 0) {
        $q = "SELECT use_both_tables FROM set_mangmnt WHERE id = " . intval($setId) . " LIMIT 1";
    } else {
        $q = "SELECT use_both_tables FROM item_management WHERE id = " . intval($partId) . " LIMIT 1";
    }
    $res = mysqli_query($conn, $q);
    if ($res && mysqli_num_rows($res) > 0) {
        return (bool) mysqli_fetch_assoc($res)['use_both_tables'];
    }
    return false;
}

/**
 * @param int|null $excludeTaskId  exclude this row's own id from the
 *                                  "what's already queued" calculation —
 *                                  used so a task (or its own leftover
 *                                  break rows) never counts as part of
 *                                  its own backlog.
 *
 * Reads original_end_date (the pure serial-walk baseline), not end_date —
 * so this never depends on overlap-resolution results, which themselves
 * depend on the mirror already existing (avoids the circular-dependency
 * inflation bug described in cnc_rebuildDualTableMirror()'s header).
 */
function cnc_getLastEndDateForTable($conn, $machineId, $tableId, $excludeTaskId = null) {
    $machineId = intval($machineId);
    $tableId   = intval($tableId);
    $excludeSql = $excludeTaskId ? (" AND id != " . intval($excludeTaskId)) : "";
    $q = mysqli_query($conn, "
        SELECT MAX(original_end_date) AS last_end
        FROM cnc_machine_table
        WHERE machine_id = $machineId AND table_id = $tableId AND finished = 0 $excludeSql
    ");
    $r = mysqli_fetch_assoc($q);
    $today = date('Y-m-d') . ' 00:00:00';
    if (!$r['last_end'] || $r['last_end'] < $today) return $today;
    return $r['last_end'];
}

/**
 * The point at which $tableId's OWN queue is actually free for a task
 * sitting at $priority — i.e. the end of whatever immediately PRECEDES
 * that priority slot on this table, not the end of the table's ENTIRE
 * backlog.
 *
 * This distinction matters a lot: cnc_getLastEndDateForTable() answers
 * "when does everything currently on this table finish" — correct for
 * measuring the OPPOSITE table (where the reservation genuinely does have
 * to wait for all existing real work, since it's appended at the back of
 * that queue). It is WRONG for measuring the task's OWN table, because if
 * the task was dropped BEFORE or in the MIDDLE of other jobs, those jobs
 * are now scheduled to run AFTER it — so "the whole table's last end_date"
 * ends up including their hours stacked on top of the task's own hours,
 * wildly overstating how long the task's own table needs before it can
 * start. That inflated number was then forced onto the opposite table as
 * the common start point, dragging real unrelated jobs there out to match
 * a number that was never real — which is exactly the "drag before/into
 * the middle of another job disturbs it" bug.
 */
function cnc_getPrecedingOriginalEndDate($conn, $machineId, $tableId, $priority, $excludeTaskId = null) {
    $machineId = intval($machineId);
    $tableId   = intval($tableId);
    $priority  = intval($priority);
    $excludeSql = $excludeTaskId ? (" AND id != " . intval($excludeTaskId)) : "";

    $q = mysqli_query($conn, "
        SELECT original_end_date FROM cnc_machine_table
        WHERE machine_id = $machineId AND table_id = $tableId AND finished = 0
          AND priority < $priority $excludeSql
        ORDER BY priority DESC LIMIT 1
    ");
    $r = mysqli_fetch_assoc($q);
    $today = date('Y-m-d') . ' 00:00:00';
    if (!$r || !$r['original_end_date'] || $r['original_end_date'] < $today) return $today;
    return $r['original_end_date'];
}

/** Deletes BOTH the alignment filler and the mirror tied to a task, wherever they currently are. */
function cnc_deleteDualTableMirror($conn, $taskId) {
    $taskId = intval($taskId);
    mysqli_query($conn, "
        DELETE FROM cnc_machine_table
        WHERE parent_task_id = $taskId AND is_break = 1
    ");
}

/**
 * Inserts a shift-aware filler break of exactly $hours duration, starting
 * at $start, at a caller-specified $priority on $tableId (shifting rows
 * at/after that priority up by one first).
 */
function cnc_createShiftAwareGapBreak($conn, $machineId, $tableId, $start, $hours, $priority, $parentTaskId, $shiftsByDay = null) {
    if ($hours <= 0) return;

    $startTs = strtotime($start);
    $endTs   = cnc_advanceTimestampByWorkHours($startTs, $hours, $conn, $shiftsByDay);
    $actualEnd = date('Y-m-d H:i:s', $endTs);

    $machineId = intval($machineId);
    $tableId   = intval($tableId);
    $priority  = intval($priority);

    mysqli_query($conn, "
        UPDATE cnc_machine_table SET priority = priority + 1
        WHERE machine_id = $machineId AND table_id = $tableId AND priority >= $priority
    ");

    $startEsc = mysqli_real_escape_string($conn, $start);
    $endEsc   = mysqli_real_escape_string($conn, $actualEnd);

    mysqli_query($conn, "
        INSERT INTO cnc_machine_table
        (machine_id, table_id, start_date, end_date, original_start_date, original_end_date,
         total_hours, original_total_hours, is_break, break_reason, priority, parent_task_id)
        VALUES
        ($machineId, $tableId, '$startEsc', '$endEsc', '$startEsc', '$endEsc',
         $hours, $hours, 1, 'Table alignment break', $priority, " . intval($parentTaskId) . ")
    ");
}

/**
 * Rebuilds a dual-table task's alignment filler (if needed) and mirror
 * break from scratch. This is the ONE place both the initial-insert path
 * and the drag/drop path call into, so they can no longer drift apart.
 *
 * Call this AFTER the main task has been moved/inserted onto its target
 * machine/table/priority, but it does NOT require the task to already
 * have correct timing — it derives everything itself from each table's
 * true backlog and fixes the task's own timing as part of the process.
 *
 * IMPORTANT: the two sides are measured differently on purpose (see
 * cnc_getPrecedingOriginalEndDate()'s header for why) — the task's own
 * table only counts backlog that precedes its priority slot, while the
 * opposite table counts its entire current backlog, since the reservation
 * there is always appended at the end of that table's own queue.
 */
function cnc_rebuildDualTableMirror($conn, $taskId) {
    $taskId = intval($taskId);
    $shiftsByDay = cnc_getShiftsByDay($conn);

    // 1. Clear any old filler/mirror tied to this task, wherever they are —
    //    BEFORE measuring backlog, so a stale filler/mirror from a previous
    //    position never pollutes the "what's really queued" comparison.
    cnc_deleteDualTableMirror($conn, $taskId);

    $task = mysqli_fetch_assoc(mysqli_query($conn, "SELECT * FROM cnc_machine_table WHERE id = $taskId LIMIT 1"));
    if (!$task) return;

    $machineId = intval($task['machine_id']);
    $tableId   = intval($task['table_id']);
    $priority  = intval($task['priority']);
    // FIX: source the mirror's reserved hours from original_total_hours,
    // never total_hours. total_hours is the LIVE column that overlap
    // resolution (cnc_processMachineScheduling) writes to -- if this task
    // was ever evaluated for overlap while its opposite table wasn't
    // protected yet (see the reordering fix in update_cnc_task_priority),
    // total_hours could transiently hold an inflated alternating-schedule
    // value. original_total_hours is never touched by overlap resolution,
    // so it's always the true, clean duration to mirror.
    $totalHours = (float) $task['original_total_hours'];
    $oppositeTableId = ($tableId == 1) ? 2 : 1;

    // 2. True backlog on each table — but measured differently per side:
    //    - own table: only what PRECEDES this task's priority slot
    //      (cnc_getPrecedingOriginalEndDate), so dropping the task before
    //      or in the middle of other jobs doesn't wrongly count their
    //      now-later hours as "backlog before this task".
    //    - opposite table: the FULL current backlog (cnc_getLastEndDateForTable),
    //      since the reservation is appended after everything already
    //      queued there.
    $endSelf     = cnc_getPrecedingOriginalEndDate($conn, $machineId, $tableId, $priority, $taskId);
    $endOpposite = cnc_getLastEndDateForTable($conn, $machineId, $oppositeTableId, null);
    $tSelf     = strtotime($endSelf);
    $tOpposite = strtotime($endOpposite);
    $commonStart = max($tSelf, $tOpposite);

    // 3. Only the side that's genuinely AHEAD gets a filler — never both,
    //    and never the empty-machine case (both equal -> no filler at all).
    if ($tSelf < $commonStart) {
        $gapHours = cnc_computeAvailableHoursBetween($tSelf, $commonStart, $conn, $shiftsByDay);
        cnc_createShiftAwareGapBreak($conn, $machineId, $tableId, $endSelf, $gapHours, $priority, $taskId, $shiftsByDay);
    }
    if ($tOpposite < $commonStart) {
        $gapHours = cnc_computeAvailableHoursBetween($tOpposite, $commonStart, $conn, $shiftsByDay);
        $pRes = mysqli_query($conn, "SELECT MAX(priority) AS p FROM cnc_machine_table WHERE machine_id=$machineId AND table_id=$oppositeTableId");
        $oppositePriority = (mysqli_fetch_assoc($pRes)['p'] ?? 0) + 1;
        cnc_createShiftAwareGapBreak($conn, $machineId, $oppositeTableId, $endOpposite, $gapHours, $oppositePriority, $taskId, $shiftsByDay);
    }

    // 4. Insert the mirror on the opposite table, appended after whatever
    //    is now last in its queue (its own real backlog, plus the filler
    //    we may have just added) — same total_hours as the main task, so
    //    the shift-walk in cnc_recalculateMachineDates() lands it on an
    //    identical wall-clock window without any manual date arithmetic.
    $pRes = mysqli_query($conn, "SELECT MAX(priority) AS p FROM cnc_machine_table WHERE machine_id=$machineId AND table_id=$oppositeTableId");
    $mirrorPriority = (mysqli_fetch_assoc($pRes)['p'] ?? 0) + 1;

    mysqli_query($conn, "
        INSERT INTO cnc_machine_table
        (machine_id, table_id, start_date, end_date, original_start_date, original_end_date,
         total_hours, original_total_hours, is_break, break_reason, priority, is_dual_table, parent_task_id)
        VALUES
        ($machineId, $oppositeTableId, '{$task['start_date']}', '{$task['end_date']}',
         '{$task['start_date']}', '{$task['end_date']}',
         $totalHours, $totalHours, 1, 'Work order occupy both tables', $mirrorPriority, 1, $taskId)
    ");
    // NOTE: start_date/end_date above are placeholders that get overwritten
    // by the very next cnc_recalculateMachineDates() pass — inserting them
    // as a rough copy of the main task's current dates just avoids a
    // transient NULL/zero-date row between now and that pass.
}

/**
 * Rebuilds EVERY dual-table job's filler/mirror on a machine, in
 * priority order per table.
 *
 * This is what makes cnc_fullRescheduleMachine() catch the general case:
 * dragging, deleting, or inserting an ORDINARY task before an EXISTING
 * dual-table job changes that dual job's true backlog exactly as much as
 * moving the dual job itself would — on either its own table, or the
 * table its mirror lives on. Rather than each endpoint trying to detect
 * "did my change happen to land near a dual job" (easy to get wrong, easy
 * to forget in a future endpoint), every dual job on the machine is
 * unconditionally rebuilt here from current backlog, every time this
 * runs. cnc_rebuildDualTableMirror() is fully idempotent (always deletes
 * and rebuilds from a fresh measurement), so redoing it for a dual job
 * that didn't actually need it is harmless — just a few extra queries.
 *
 * Priority order per table matters: an earlier dual job's own filler can
 * change size, which is exactly the kind of "backlog before me changed"
 * event a LATER dual job on the same table needs to see — so earlier
 * ones must settle first.
 */
function cnc_resyncAllDualTableMirrorsOnMachine($conn, $machineId) {
    $machineId = intval($machineId);

    // Fresh position-only baseline before touching anything.
    cnc_recalculateMachineDates($conn, $machineId, 1, true);
    cnc_recalculateMachineDates($conn, $machineId, 2, true);

    $res = mysqli_query($conn, "
        SELECT id FROM cnc_machine_table
        WHERE machine_id = $machineId AND is_dual_table = 1 AND is_break = 0 AND finished = 0
        ORDER BY table_id, priority
    ");
    $taskIds = [];
    while ($row = mysqli_fetch_assoc($res)) $taskIds[] = intval($row['id']);

    foreach ($taskIds as $tid) {
        cnc_rebuildDualTableMirror($conn, $tid);

        // Refresh positions before the next dual job's measurement —
        // this rebuild may have inserted/resized a filler or mirror that
        // shifts priorities/backlog the next one depends on.
        cnc_recalculateMachineDates($conn, $machineId, 1, true);
        cnc_recalculateMachineDates($conn, $machineId, 2, true);
    }
}

/**
 * Keeps a dual-table task's mirror hours in sync with the main task —
 * needed because breakdown handling and splits can change a main task's
 * total_hours/original_total_hours without anything else telling its
 * mirror. Returns true if any mirror was updated (caller should reschedule
 * again afterward).
 */
function cnc_syncDualTableMirrors($conn, $machineId) {
    $machineId = intval($machineId);
    $changed = false;

    $res = mysqli_query($conn, "
        SELECT id, total_hours, original_total_hours
        FROM cnc_machine_table
        WHERE machine_id = $machineId AND is_dual_table = 1 AND is_break = 0 AND finished = 0
    ");
    while ($mainRow = mysqli_fetch_assoc($res)) {
        $mainId = intval($mainRow['id']);
        $mirror = mysqli_fetch_assoc(mysqli_query($conn, "
            SELECT id, total_hours, original_total_hours FROM cnc_machine_table
            WHERE parent_task_id = $mainId AND is_break = 1 AND is_dual_table = 1 AND finished = 0
            LIMIT 1
        "));
        if ($mirror) {
            $hoursDiff = abs((float) $mirror['total_hours'] - (float) $mainRow['total_hours']);
            $origDiff  = abs((float) $mirror['original_total_hours'] - (float) $mainRow['original_total_hours']);
            if ($hoursDiff > 0.01 || $origDiff > 0.01) {
                mysqli_query($conn, "
                    UPDATE cnc_machine_table
                    SET total_hours = {$mainRow['total_hours']}, original_total_hours = {$mainRow['original_total_hours']}
                    WHERE id = {$mirror['id']}
                ");
                $changed = true;
            }
        }
    }
    return $changed;
}

/**
 * When a dual-table main task finishes (fully, or the "completed" half of
 * a breakdown split), its mirror/filler on the opposite table must be
 * finished too — otherwise it permanently occupies the opposite table's
 * schedule for a job that no longer needs it.
 */
function cnc_finishDualTablePartnerRows($conn, $taskId) {
    mysqli_query($conn, "
        UPDATE cnc_machine_table SET finished = 1
        WHERE parent_task_id = " . intval($taskId) . " AND is_break = 1
    ");
}

/* =====================================================================
 * SPLIT WORK ORDER (single implementation for both the manual "Split"
 * action and the breakdown partial-completion split)
 * ===================================================================== */

/**
 * Splits a cnc_machine_table row (and its linked manufacture_machine_table
 * row) into two portions, or an arbitrary qty split.
 *
 * FIX (#8): now always propagates is_dual_table onto the new row, and
 * resets original_uph on both halves (a UPH baseline computed against the
 * old full quantity is meaningless against a smaller split quantity).
 *
 * FIX: $keepOverrides / $splitOverrides are now separate, because which
 * half ends up "finished" differs by caller:
 *   - manual "Split Work Order": neither half finishes, both keep going.
 *   - breakdown partial-completion split: the ORIGINAL row (shrunk to the
 *     already-produced qty) is the one that finishes; the NEW row (the
 *     remaining qty) is the one that keeps running. The previous version
 *     of this function only accepted overrides for the new row, which is
 *     backwards for the breakdown case.
 *
 * @param int   $taskId          cnc_machine_table.id to split
 * @param float $keepQty         quantity that STAYS on the original row
 * @param float $splitQty        quantity that moves to the NEW row
 * @param array $keepOverrides   optional field overrides for the ORIGINAL row, e.g. ['finished' => 1]
 * @param array $splitOverrides  optional field overrides for the NEW row
 * @return array|false           ['original_hours'=>, 'split_hours'=>, 'new_manuf_id'=>, 'new_task_id'=>, 'is_dual_table'=>]
 */
function cnc_splitCncTask($conn, $taskId, $keepQty, $splitQty, $keepOverrides = [], $splitOverrides = []) {
    $taskId = intval($taskId);

    $res = mysqli_query($conn, "SELECT * FROM cnc_machine_table WHERE id = $taskId LIMIT 1");
    if (!$res || mysqli_num_rows($res) == 0) return false;
    $task = mysqli_fetch_assoc($res);

    $currentQty  = floatval($task['quantity']);
    $setupHours  = floatval($task['setup_hours']);
    $totalHours  = floatval($task['total_hours']);
    $manufId     = isset($task['manuf_id']) ? intval($task['manuf_id']) : 0;
    $isDualTable = intval($task['is_dual_table']);

    if ($keepQty < 0 || $splitQty <= 0 || ($keepQty + $splitQty) > $currentQty + 0.0001) {
        return false;
    }

    $hoursWithoutSetup = max(0, $totalHours - $setupHours);
    $hoursPerUnit = $currentQty > 0 ? ($hoursWithoutSetup / $currentQty) : 0;

    // Only the portion that STARTS with setup keeps the setup hours; if the
    // job is being split into "already done" + "remaining", the remaining
    // portion still needs its own setup on resume, so both halves keep it.
    $keepHours  = ($keepQty  * $hoursPerUnit) + $setupHours;
    $splitHours = ($splitQty * $hoursPerUnit) + $setupHours;

    // --- linked manufacture_machine_table split -------------------------
    $newManufId = null;
    if ($manufId > 0) {
        $manufRes = mysqli_query($conn, "SELECT * FROM manufacture_machine_table WHERE id = $manufId LIMIT 1");
        if ($manufRes && mysqli_num_rows($manufRes) > 0) {
            $origManuf = mysqli_fetch_assoc($manufRes);
            $manufQty  = floatval($origManuf['quantity']);
            $manufHrs  = floatval($origManuf['total_hours']);
            $manufHoursPerUnit = $manufQty > 0 ? ($manufHrs / $manufQty) : 0;

            $manufKeepHours  = $keepQty  * $manufHoursPerUnit;
            $manufSplitHours = $splitQty * $manufHoursPerUnit;

            mysqli_query($conn, "
                UPDATE manufacture_machine_table
                SET quantity = $keepQty, total_hours = $manufKeepHours
                WHERE id = $manufId
            ");

            $cols = []; $vals = [];
            foreach ($origManuf as $col => $val) {
                if ($col === 'id') continue;
                $cols[] = "`$col`";
                if ($col === 'quantity')      $vals[] = $splitQty;
                elseif ($col === 'total_hours') $vals[] = $manufSplitHours;
                elseif (is_null($val))        $vals[] = 'NULL';
                else $vals[] = "'" . mysqli_real_escape_string($conn, $val) . "'";
            }
            mysqli_query($conn, "INSERT INTO manufacture_machine_table (" . implode(',', $cols) . ") VALUES (" . implode(',', $vals) . ")");
            $newManufId = mysqli_insert_id($conn);
        }
    }

    // --- original row: shrink to keepQty ----------------------------------
    $keepFinished = isset($keepOverrides['finished']) ? intval($keepOverrides['finished']) : intval($task['finished']);
    mysqli_query($conn, "
        UPDATE cnc_machine_table SET
            quantity              = $keepQty,
            total_hours           = $keepHours,
            total_hours_update    = $keepHours,
            original_total_hours  = $keepHours,
            original_uph          = 0,
            finished               = $keepFinished
        WHERE id = $taskId
    ");

    // If the KEPT half is finishing (breakdown "completed portion" case)
    // and it's dual-table, its mirror must finish with it — otherwise the
    // mirror keeps blocking the opposite table for a job that's now done.
    if ($keepFinished == 1 && $isDualTable == 1) {
        cnc_finishDualTablePartnerRows($conn, $taskId);
    }

    // --- bump priority of everything after this task ----------------------
    $machineId = intval($task['machine_id']);
    $tableId   = intval($task['table_id']);
    $priority  = intval($task['priority']);
    mysqli_query($conn, "
        UPDATE cnc_machine_table SET priority = priority + 1
        WHERE machine_id = $machineId AND table_id = $tableId AND priority > $priority
    ");

    // --- insert new row for the split-off qty ------------------------------
    $manufIdForInsert = $newManufId !== null ? intval($newManufId) : ($manufId > 0 ? $manufId : 'NULL');
    $splitFinished = isset($splitOverrides['finished']) ? intval($splitOverrides['finished']) : 0;

    mysqli_query($conn, "
        INSERT INTO cnc_machine_table (
            machine_id, table_id, work_order_id, part_id, manuf_id, product_name, quantity,
            setup_hours, total_hours, total_hours_update, original_total_hours,
            priority, start_date, end_date, shift_ids, cycle, set_id,
            is_break, break_reason, finished, remaing_hours,
            original_start_date, original_end_date, is_dual_table, original_uph
        ) VALUES (
            $machineId, $tableId, {$task['work_order_id']}, {$task['part_id']}, $manufIdForInsert,
            '" . mysqli_real_escape_string($conn, $task['product_name']) . "', $splitQty,
            $setupHours, $splitHours, $splitHours, $splitHours,
            " . ($priority + 1) . ",
            '{$task['start_date']}', '{$task['end_date']}', '{$task['shift_ids']}',
            {$task['cycle']}, " . intval($task['set_id']) . ",
            0, '', $splitFinished, '',
            '{$task['original_start_date']}', '{$task['original_end_date']}', $isDualTable, 0
        )
    ");
    $newTaskId = mysqli_insert_id($conn);

    // If the split-off (new) row is the one still running and it's
    // dual-table, it needs a fresh mirror of its own — the old mirror
    // belonged to the pre-split job and was either finished above (if the
    // kept half finished) or is still correctly attached to the kept half.
    // Building the new row's mirror is left to the caller (after this
    // function returns) via cnc_rebuildDualTableMirror($conn, $newTaskId),
    // since that also requires a reschedule pass to have correct timing to
    // build from — bundling it here would mean rescheduling mid-transaction.

    return [
        'original_hours' => $keepHours,
        'split_hours'    => $splitHours,
        'new_manuf_id'   => $newManufId,
        'new_task_id'    => $newTaskId,
        'is_dual_table'  => $isDualTable,
    ];
}

/* =====================================================================
 * BREAKDOWN — UNIFIED STATUS SOURCE
 * ---------------------------------------------------------------------
 * Previously the Gantt admin page (machine_breakdown table) and the shop
 * floor tablet (cnc_machine_breakdown_log table) were two independent
 * "is this machine down" signals that could disagree indefinitely — e.g.
 * office starts a breakdown on the Gantt page and the tablet operator
 * never sees the red screen, or vice versa.
 *
 * cnc_machine_breakdown_log is now the single source of truth for "is the
 * machine currently in breakdown". The Gantt-page endpoints
 * (start_machine_breakdown / stop_machine_breakdown) still maintain
 * machine_breakdown for historical duration reporting, but ALSO write a
 * mirrored row into cnc_machine_breakdown_log so isMachineInBreakdown()
 * (used by the tablet dashboard) sees it immediately, and vice versa.
 * ===================================================================== */

function cnc_isMachineInBreakdown($conn, $machineId) {
    $machineId = intval($machineId);
    $sql = "SELECT status FROM cnc_machine_breakdown_log
            WHERE machine_id = $machineId
            ORDER BY created_at DESC, id DESC
            LIMIT 1";
    $res = mysqli_query($conn, $sql);
    if ($res && mysqli_num_rows($res) > 0) {
        return mysqli_fetch_assoc($res)['status'] === 'active';
    }
    return false;
}

/**
 * Mark tasks overlapping a breakdown window as is_breakdown_affected = 1,
 * scoped by machine (there's still no per-breakdown-event scoping on the
 * task row itself — flagged in CHANGELOG.md as a follow-up if you need to
 * distinguish which of several breakdown events affected a given task).
 */
function cnc_markBreakdownAffectedWorkOrders($conn, $machineId, $breakdownStart) {
    $machineId = intval($machineId);
    $startEsc  = mysqli_real_escape_string($conn, $breakdownStart);

    $res = mysqli_query($conn, "
        SELECT id FROM cnc_machine_table
        WHERE machine_id = $machineId
          AND is_break = 0
          AND start_date < '$startEsc'
          AND end_date   > '$startEsc'
    ");
    $count = 0;
    while ($row = mysqli_fetch_assoc($res)) {
        mysqli_query($conn, "UPDATE cnc_machine_table SET is_breakdown_affected = 1 WHERE id = {$row['id']}");
        $count++;
    }
    return $count;
}
?>