<?php
// ============================================================
// Seyalix HRMS – Mobile App REST API  (api/v1/index.php)
// ============================================================
// All endpoints:  POST /api/v1/?action=<action>
// Auth header:    Authorization: Bearer <token>
// ============================================================

header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }

require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../../includes/functions.php'; // shared notify()/getManagerId()/notifyReportingHead()

$db     = Database::getInstance();
$action = $_GET['action'] ?? $_POST['action'] ?? '';
$input  = json_decode(file_get_contents('php://input'), true) ?? [];
$input  = array_merge($_GET, $_POST, $input);

// ── Helpers ──────────────────────────────────────────────────
function apiSuccess(array $data = [], string $msg = 'Success'): void {
    echo json_encode(['success'=>true,'message'=>$msg,'data'=>$data,'timestamp'=>time()]);
    exit;
}
// NOTE: default code is 200, not 400. Retrofit's Response<T>.body() is only
// populated for 2xx responses — a 4xx here would silently discard the error
// message (it lands in errorBody() instead), which the app never reads.
// Genuine HTTP-semantic errors (auth, forbidden, not-found) still pass an
// explicit code below and are unaffected.
function apiError(string $msg, int $code = 200): void {
    http_response_code($code);
    echo json_encode(['success'=>false,'message'=>$msg,'data'=>null,'timestamp'=>time()]);
    exit;
}
function sanitize(mixed $v): string { return strip_tags(trim((string)$v)); }

// ── Auth Token Validation ────────────────────────────────────
function getAuthHeader(): string {
    // Apache (esp. on XAMPP/Windows with mod_php) frequently does NOT forward
    // the Authorization header into $_SERVER['HTTP_AUTHORIZATION'], so we
    // fall back to apache_request_headers() / getallheaders() as well.
    if (!empty($_SERVER['HTTP_AUTHORIZATION'])) return $_SERVER['HTTP_AUTHORIZATION'];
    if (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];

    if (function_exists('getallheaders')) {
        foreach (getallheaders() as $name => $value) {
            if (strcasecmp($name, 'Authorization') === 0) return $value;
        }
    }
    if (function_exists('apache_request_headers')) {
        foreach (apache_request_headers() as $name => $value) {
            if (strcasecmp($name, 'Authorization') === 0) return $value;
        }
    }
    return '';
}

function validateToken(Database $db): array {
    $auth  = getAuthHeader();
    $token = '';
    if (preg_match('/Bearer\s+(.+)$/i', $auth, $m)) $token = $m[1];
    if (!$token) $token = $_GET['token'] ?? $_POST['token'] ?? '';
    if (!$token) apiError('No token provided', 401);

    $row = $db->fetchOne(
        "SELECT at.*, e.id AS emp_id, e.employee_id AS emp_code,
                e.first_name, e.last_name, e.status AS emp_status, e.company_id,
                u.role, u.id AS user_id
           FROM api_tokens at
           JOIN employees e ON e.id = at.employee_id
           JOIN users u ON u.employee_id = e.id
          WHERE at.token=? AND at.is_active=1 AND at.expires_at > NOW()",
        [$token]
    );
    if (!$row) apiError('Invalid or expired token', 401);
    if ($row['emp_status'] !== 'active') apiError('Employee account is inactive', 403);
    return $row;
}

// FIX (10 Jul 2026): api/v1/index.php never got the same organization
// data-isolation pass the web modules did — a manager using the mobile
// app could see/approve another organization's leave, permission, late,
// or muster-modification requests. This mirrors orgCompanyIds() /
// orgScopeClause() from includes/functions.php (web side) for the API's
// token-based auth context.
function apiOrgCompanyIds(Database $db, array $authUser): array {
    $orgId = $db->fetchValue("SELECT organization_id FROM companies WHERE id=?", [$authUser['company_id']]);
    if (!$orgId) return [$authUser['company_id']]; // fail to just their own company, not open
    return array_column($db->fetchAll("SELECT id FROM companies WHERE organization_id=?", [$orgId]), 'id');
}

function apiOrgScopeClause(Database $db, array $authUser, string $column): array {
    $ids = apiOrgCompanyIds($db, $authUser);
    if (!$ids) return ["$column = -1", []];
    return ["$column IN (" . implode(',', array_fill(0, count($ids), '?')) . ")", $ids];
}

// ── Notifications ─────────────────────────────────────────────
// Requires the `notifications` table — see migration_notifications.sql
// notify() / getManagerId() / notifyReportingHead() now live in
// includes/functions.php (shared with the web app — see FIX 30 Jul 2026
// there for why).

// (Notification endpoints — get_notifications, unread_notification_count,
// mark_notification_read — are registered further below, after auth setup.)

// ── Generate Token ────────────────────────────────────────────
function generateToken(int $empId, Database $db): string {
    $token  = bin2hex(random_bytes(32));
    $expiry = date('Y-m-d H:i:s', time() + API_TOKEN_EXPIRY);
    // Deactivate old tokens
    $db->execute("UPDATE api_tokens SET is_active=0 WHERE employee_id=?", [$empId]);
    $db->insertRow('api_tokens', [
        'employee_id' => $empId,
        'token'       => $token,
        'expires_at'  => $expiry,
        'is_active'   => 1,
    ]);
    return $token;
}

// ============================================================
// PUBLIC ENDPOINTS (No auth required)
// ============================================================

// ── APP VERSION (for the update banner) ───────────────────────
// Requires the `app_config` table — see migration_app_config.sql
if ($action === 'app_version') {
    $rows = $db->fetchAll("SELECT config_key, config_value FROM app_config WHERE config_key IN ('latest_version_code','latest_version_name','update_message','update_url')");
    $cfg = [];
    foreach ($rows as $r) { $cfg[$r['config_key']] = $r['config_value']; }
    apiSuccess([
        'latest_version_code' => (int)($cfg['latest_version_code'] ?? 1),
        'latest_version_name' => $cfg['latest_version_name'] ?? '1.0',
        'message'             => $cfg['update_message'] ?? 'A new version of the app is available.',
        'update_url'          => $cfg['update_url'] ?? '',
    ], 'Version info fetched');
}

// ── LOGIN ────────────────────────────────────────────────────
if ($action === 'login') {
    $username = sanitize($input['username'] ?? '');
    $password = $input['password'] ?? '';
    if (!$username || !$password) apiError('Username and password required');

    $user = $db->fetchOne(
        "SELECT u.*, e.id AS emp_id, e.employee_id AS emp_code,
                e.first_name, e.last_name, e.photo, e.department_id,
                d.name AS dept_name, des.name AS designation
           FROM users u
           JOIN employees e ON e.id=u.employee_id
      LEFT JOIN departments d ON d.id=e.department_id
      LEFT JOIN designations des ON des.id=e.designation_id
          WHERE u.username=? AND u.is_active=1",
        [$username]
    );

    if (!$user || !password_verify($password, $user['password_hash'])) {
        apiError('Invalid credentials', 401);
    }

    // 07 Jul 2026: license is sold per-organization; if the vendor
    // suspends an organization, every company (and every user) under it
    // is blocked here too, not just on the web app.
    $org = $db->fetchOne(
        "SELECT o.* FROM companies c JOIN organizations o ON o.id=c.organization_id WHERE c.id=?",
        [$user['company_id']]
    );
    if (!$org || $org['status'] !== 'active') {
        apiError('Your organization\'s access is currently suspended. Please contact your provider.', 403);
    }

    $token = generateToken($user['emp_id'], $db);
    $db->execute("UPDATE users SET last_login=NOW() WHERE id=?", [$user['id']]);

    apiSuccess([
        'token'          => $token,
        'expires_in'     => API_TOKEN_EXPIRY,
        'employee_id'    => $user['emp_id'],
        'emp_code'       => $user['emp_code'],
        'name'           => $user['first_name'] . ' ' . $user['last_name'],
        'role'           => $user['role'],
        'department'     => $user['dept_name'],
        'designation'    => $user['designation'],
        'photo'          => $user['photo'] ? BASE_URL . '/uploads/' . $user['photo'] : null,
    ], 'Login successful');
}

// ── REFRESH TOKEN ────────────────────────────────────────────
if ($action === 'refresh_token') {
    $user  = validateToken($db);
    $token = generateToken($user['emp_id'], $db);
    apiSuccess(['token'=>$token,'expires_in'=>API_TOKEN_EXPIRY], 'Token refreshed');
}

// ── LOGOUT ───────────────────────────────────────────────────
if ($action === 'logout') {
    $user = validateToken($db);
    $db->execute("UPDATE api_tokens SET is_active=0 WHERE employee_id=?", [$user['emp_id']]);
    apiSuccess([], 'Logged out');
}

// ============================================================
// PROTECTED ENDPOINTS
// ============================================================

// All actions below require auth
$authUser = validateToken($db);
$empId    = $authUser['emp_id'];

// ── NOTIFICATIONS ─────────────────────────────────────────────
// Requires the `notifications` table — see migration_notifications.sql
if ($action === 'get_notifications') {
    $notifs = $db->fetchAll(
        "SELECT * FROM notifications WHERE employee_id=? ORDER BY created_at DESC LIMIT 50",
        [$empId]
    );
    apiSuccess($notifs, 'Notifications fetched');
}

if ($action === 'unread_notification_count') {
    $count = (int)$db->fetchValue(
        "SELECT COUNT(*) FROM notifications WHERE employee_id=? AND is_read=0",
        [$empId]
    );
    apiSuccess(['count' => $count], 'Unread count fetched');
}

if ($action === 'mark_notification_read') {
    $notifId = (int)($input['notification_id'] ?? 0);
    if ($notifId) {
        $db->execute("UPDATE notifications SET is_read=1 WHERE id=? AND employee_id=?", [$notifId, $empId]);
    } else {
        // no id -> mark all as read (kept for backward compatibility)
        $db->execute("UPDATE notifications SET is_read=1 WHERE employee_id=? AND is_read=0", [$empId]);
    }
    apiSuccess([], 'Marked as read');
}

// FIX (10 Jul 2026): new — mark a single notification back to unread,
// and explicit mark-all-read / mark-all-unread actions for the bell
// popup's "Mark all as read" / "Mark all as unread" buttons.
if ($action === 'mark_notification_unread') {
    $notifId = (int)($input['notification_id'] ?? 0);
    if (!$notifId) apiError('notification_id is required');
    $db->execute("UPDATE notifications SET is_read=0 WHERE id=? AND employee_id=?", [$notifId, $empId]);
    apiSuccess([], 'Marked as unread');
}

if ($action === 'mark_all_read') {
    $db->execute("UPDATE notifications SET is_read=1 WHERE employee_id=?", [$empId]);
    apiSuccess([], 'All notifications marked as read');
}

if ($action === 'mark_all_unread') {
    $db->execute("UPDATE notifications SET is_read=0 WHERE employee_id=?", [$empId]);
    apiSuccess([], 'All notifications marked as unread');
}

// ── PRODUCT UPDATE BROADCAST ─────────────────────────────────
// FIX (10 Jul 2026): restored — was in an earlier version of this file
// but missing from this lineage. Vendor posts an update from
// modules/companies/index.php; every company's app can check here (also
// included inline in 'dashboard' as 'latest_update').
if ($action === 'get_latest_update') {
    $update = $db->fetchOne("SELECT id,version,title,message,created_at FROM product_updates WHERE is_active=1 ORDER BY id DESC LIMIT 1");
    apiSuccess(['update' => $update]);
}

// ── PROFILE ──────────────────────────────────────────────────
if ($action === 'get_profile') {
    $emp = $db->fetchOne(
        "SELECT e.*, d.name AS dept, des.name AS designation,
                l.name AS location, b.name AS branch,
                g.name AS grade, s.name AS shift,
                es.gross_monthly, es.ctc_annual
           FROM employees e
      LEFT JOIN departments d ON d.id=e.department_id
      LEFT JOIN designations des ON des.id=e.designation_id
      LEFT JOIN locations l ON l.id=e.location_id
      LEFT JOIN branches b ON b.id=e.branch_id
      LEFT JOIN grades g ON g.id=e.grade_id
      LEFT JOIN shifts s ON s.id=e.shift_id
      LEFT JOIN employee_salary es ON es.employee_id=e.id
          WHERE e.id=?", [$empId]
    );
    unset($emp['aadhaar_number']); // mask sensitive
    apiSuccess(['employee'=>$emp]);
}

// ── DASHBOARD SUMMARY ────────────────────────────────────────
if ($action === 'dashboard') {
    $today = date('Y-m-d');
    $muster = $db->fetchOne("SELECT * FROM muster WHERE employee_id=? AND muster_date=?", [$empId,$today]);
    $pendLeave = $db->fetchValue("SELECT COUNT(*) FROM leave_applications WHERE employee_id=? AND status='pending'", [$empId]);
    $balances  = $db->fetchAll(
        "SELECT lb.*, lt.name AS name, lt.code,
                (lb.allocated + lb.carry_forward - lb.used) AS available
           FROM employee_leave_balance lb JOIN leave_types lt ON lt.id=lb.leave_type_id
          WHERE lb.employee_id=? AND lb.year=?", [$empId, date('Y')]
    );
    // FIX (10 Jul 2026): same disconnected-table bug as the standalone
    // 'notices' action — this was reading from 'notices' (which the web
    // app never writes to) instead of 'notice_board', and had no org scope.
    [$noticeOrgClause, $noticeOrgParams] = apiOrgScopeClause($db, $authUser, 'company_id');
    $notices = $db->fetchAll(
        "SELECT id, title, category, created_at AS published_at
           FROM notice_board
          WHERE is_active=1 AND (expiry_date IS NULL OR expiry_date >= CURDATE()) AND $noticeOrgClause
          ORDER BY priority DESC, created_at DESC LIMIT 5",
        $noticeOrgParams
    );
    $latestPayslip = $db->fetchOne(
        "SELECT month,year,gross_salary,net_salary FROM payroll_details WHERE employee_id=? ORDER BY year DESC,month DESC LIMIT 1",
        [$empId]
    );

    // FIX (10 Jul 2026): these three were added in an earlier pass but
    // never made it into this file's lineage — restoring them here, org-scoped.
    $pendingApprovalsCount = 0;
    if (in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) {
        $isManagementRole = in_array($authUser['role'], ['hr','management','admin','superadmin']);
        [$mgrOrgClause, $mgrOrgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
        $mgrWhere  = $isManagementRole ? "(e.reporting_manager_id=? OR e.reporting_manager_id IS NULL) AND $mgrOrgClause" : "e.reporting_manager_id=? AND $mgrOrgClause";
        $mgrParams = array_merge([$empId], $mgrOrgParams);
        $pendingApprovalsCount =
            (int)$db->fetchValue("SELECT COUNT(*) FROM leave_applications la JOIN employees e ON e.id=la.employee_id WHERE la.status='pending' AND $mgrWhere", $mgrParams) +
            (int)$db->fetchValue("SELECT COUNT(*) FROM permission_requests pr JOIN employees e ON e.id=pr.employee_id WHERE pr.status='pending' AND $mgrWhere", $mgrParams) +
            (int)$db->fetchValue("SELECT COUNT(*) FROM late_regularizations lr JOIN employees e ON e.id=lr.employee_id WHERE lr.status='pending' AND $mgrWhere", $mgrParams) +
            (int)$db->fetchValue("SELECT COUNT(*) FROM on_duty_requests od JOIN employees e ON e.id=od.employee_id WHERE od.status='pending' AND $mgrWhere", $mgrParams);
    }
    $unreadNotifications = (int)$db->fetchValue("SELECT COUNT(*) FROM notifications WHERE employee_id=? AND is_read=0", [$empId]);
    $latestUpdate = $db->fetchOne("SELECT id,version,title,message,created_at FROM product_updates WHERE is_active=1 ORDER BY id DESC LIMIT 1");

    apiSuccess([
        'today_status'            => musterStatusLabel($muster['status'] ?? null),
        'check_in'                => $muster['in_time']  ?? null,
        'check_out'               => $muster['out_time'] ?? null,
        'pending_leaves'          => (int)$pendLeave,
        'pending_approvals_count' => $pendingApprovalsCount,
        'unread_notifications'    => $unreadNotifications,
        'latest_update'           => $latestUpdate,
        'leave_balances'          => $balances,
        'notices'                 => $notices,
        'latest_payslip'          => $latestPayslip,
    ]);
}

// ── CHECK IN ─────────────────────────────────────────────────
// ── Geofence helper ──────────────────────────────────────────
function haversineDistance(float $lat1, float $lon1, float $lat2, float $lon2): float {
    $R    = 6371000; // Earth radius in metres
    $dLat = deg2rad($lat2 - $lat1);
    $dLon = deg2rad($lon2 - $lon1);
    $a    = sin($dLat/2)*sin($dLat/2) + cos(deg2rad($lat1))*cos(deg2rad($lat2))*sin($dLon/2)*sin($dLon/2);
    return $R * 2 * atan2(sqrt($a), sqrt(1-$a));
}

if ($action === 'check_in') {
    $today   = date('Y-m-d');
    $now     = date('Y-m-d H:i:s');
    $lat     = isset($input['latitude'])  ? (float)$input['latitude']  : null;
    $lng     = isset($input['longitude']) ? (float)$input['longitude'] : null;
    $deviceId= sanitize($input['device_id'] ?? '');

    // ── Geofence check ────────────────────────────────────────
    if ($lat === null || $lng === null) {
        apiError('GPS location is required for check-in. Please enable location access.', 400);
    }

    // Get employee's assigned location
    $empLocation = $db->fetchOne(
        "SELECT l.latitude, l.longitude, l.geofence_radius, l.name AS location_name
           FROM employees e
           JOIN locations l ON l.id = e.location_id
          WHERE e.id = ? AND l.latitude IS NOT NULL AND l.latitude != 0",
        [$empId]
    );

    if ($empLocation) {
        $distance = haversineDistance(
            (float)$empLocation['latitude'],
            (float)$empLocation['longitude'],
            $lat, $lng
        );
        $radius = (int)($empLocation['geofence_radius'] ?? 25);
        if ($distance > $radius) {
            $distanceStr = round($distance) . 'm';
            apiError(
                "You are {$distanceStr} away from {$empLocation['location_name']}. " .
                "You must be within {$radius}m to check-in. Please move closer to the office.",
                403
            );
        }
    }
    // If no location assigned, allow check-in (graceful fallback)

    // Check already checked in today
    $existing = $db->fetchOne("SELECT * FROM muster WHERE employee_id=? AND muster_date=?", [$empId,$today]);
    if ($existing && $existing['in_time']) {
        apiError('Already checked in at ' . date('h:i A', strtotime($existing['in_time'])));
    }

    // Log raw attendance
    $db->insertRow('attendance_log', [
        'employee_id' => $empId,
        'log_date'    => $today,
        'in_time'     => $now,
        'source'      => 'mobile_app',
        'device_id'   => $deviceId,
        'latitude'    => $lat,
        'longitude'   => $lng,
        'ip_address'  => $_SERVER['REMOTE_ADDR'] ?? '',
    ]);

    // Update muster
    if ($existing) {
        $db->execute("UPDATE muster SET in_time=?,status='P' WHERE employee_id=? AND muster_date=?",
            [date('H:i:s'), $empId, $today]);
    } else {
        $db->insertRow('muster', [
            'employee_id' => $empId,
            'muster_date' => $today,
            'in_time'     => date('H:i:s'),
            'status'      => 'P',
        ]);
    }

    apiSuccess([
        'check_in_time' => date('h:i A'),
        'date'          => date('D, d M Y'),
    ], 'Check-in recorded successfully');
}

// ── CHECK OUT ────────────────────────────────────────────────
if ($action === 'check_out') {
    $today = date('Y-m-d');
    $now   = date('Y-m-d H:i:s');
    $lat   = isset($input['latitude'])  ? (float)$input['latitude']  : null;
    $lng   = isset($input['longitude']) ? (float)$input['longitude'] : null;

    // ── Geofence check ────────────────────────────────────────
    if ($lat === null || $lng === null) {
        apiError('GPS location is required for check-out. Please enable location access.', 400);
    }

    $empLocation = $db->fetchOne(
        "SELECT l.latitude, l.longitude, l.geofence_radius, l.name AS location_name
           FROM employees e
           JOIN locations l ON l.id = e.location_id
          WHERE e.id = ? AND l.latitude IS NOT NULL AND l.latitude != 0",
        [$empId]
    );

    if ($empLocation) {
        $distance = haversineDistance(
            (float)$empLocation['latitude'],
            (float)$empLocation['longitude'],
            $lat, $lng
        );
        $radius = (int)($empLocation['geofence_radius'] ?? 25);
        if ($distance > $radius) {
            $distanceStr = round($distance) . 'm';
            apiError(
                "You are {$distanceStr} away from {$empLocation['location_name']}. " .
                "You must be within {$radius}m to check-out.",
                403
            );
        }
    }

    $existing = $db->fetchOne("SELECT * FROM muster WHERE employee_id=? AND muster_date=?", [$empId,$today]);
    if (!$existing || !$existing['in_time']) apiError('No check-in found for today');
    if ($existing['out_time']) apiError('Already checked out at ' . date('h:i A', strtotime($existing['out_time'])));

    $workHours = round((strtotime($now) - strtotime($today.' '.$existing['in_time'])) / 3600, 2);
    $status    = $workHours >= 7 ? 'P' : ($workHours >= 4 ? 'H' : 'P');

    $db->execute(
        "UPDATE muster SET out_time=?,work_hours=?,status=? WHERE employee_id=? AND muster_date=?",
        [date('H:i:s'), $workHours, $status, $empId, $today]
    );

    // Update log
    $db->execute(
        "UPDATE attendance_log SET out_time=? WHERE employee_id=? AND log_date=? AND source='mobile_app' ORDER BY id DESC LIMIT 1",
        [$now, $empId, $today]
    );

    apiSuccess([
        'check_out_time' => date('h:i A'),
        'work_hours'     => $workHours,
        'status'         => $status,
    ], 'Check-out recorded successfully');
}

// ── ATTENDANCE HISTORY ────────────────────────────────────────
if ($action === 'attendance_history') {
    $month = (int)($input['month'] ?? date('n'));
    $year  = (int)($input['year']  ?? date('Y'));

    $records = $db->fetchAll(
        "SELECT id, muster_date AS date, status, in_time AS check_in, out_time AS check_out,
                work_hours AS working_hours, is_late, COALESCE(late_minutes,0) AS late_minutes
           FROM muster WHERE employee_id=? AND MONTH(muster_date)=? AND YEAR(muster_date)=?
           ORDER BY muster_date",
        [$empId, $month, $year]
    );

    // 'data' must be a plain array to match the Android app's List<AttendanceRecord> model
    apiSuccess($records, 'Attendance history fetched');
}

// ── LEAVE BALANCE ─────────────────────────────────────────────
if ($action === 'leave_balance') {
    $balances = $db->fetchAll(
        "SELECT lb.*, lt.name AS name, lt.code, lt.is_paid,
                (lb.allocated + lb.carry_forward - lb.used) AS available
           FROM employee_leave_balance lb
           JOIN leave_types lt ON lt.id=lb.leave_type_id
          WHERE lb.employee_id=? AND lb.year=?
          ORDER BY lt.name",
        [$empId, date('Y')]
    );
    // 'data' must be a plain array to match the Android app's List<LeaveBalance> model
    apiSuccess($balances, 'Leave balance fetched');
}

// ── LEAVE TYPES ─────────────────────────────────────────────
if ($action === 'leave_types') {
    $types = $db->fetchAll("SELECT id, name, code FROM leave_types ORDER BY name");
    // 'data' must be a plain array to match the Android app's List<LeaveType> model
    apiSuccess($types, 'Leave types fetched');
}

// ── APPLY LEAVE ───────────────────────────────────────────────
if ($action === 'apply_leave') {
    $ltId   = (int)($input['leave_type_id'] ?? 0);
    $from   = sanitize($input['start_date'] ?? '');
    $to     = sanitize($input['end_date']   ?? '');
    $isHalf = (int)($input['is_half_day']  ?? 0);
    $reason = sanitize($input['reason']    ?? '');

    if (!$ltId || !$from || !$to || !$reason) apiError('All fields are required');
    // Allow applying for any date within the CURRENT cycle (1st of this month
    // onward), even if it's already past — only block genuinely old months.
    if (strtotime($from) < strtotime(date('Y-m-01'))) {
        apiError('You can only apply leave within the current cycle (' . date('M Y') . ')');
    }

    // FIX (06 Jul 2026): same start/end date (e.g. 03-06-2026 to 03-06-2026,
    // a 1-day leave) was counting as 2 days. The old formula was
    // max(1, diff) + 1 — for diff=0 that's max(1,0)+1 = 1+1 = 2. The +1
    // (to make the range inclusive of both start and end dates) has to
    // happen BEFORE the max(1, ...) floor, not after it.
    $days = $isHalf ? 0.5 : max(1, ((strtotime($to) - strtotime($from)) / 86400) + 1);

    $bal = $db->fetchOne(
        "SELECT (COALESCE(allocated,0)+COALESCE(carry_forward,0)-COALESCE(used,0)) AS available
           FROM employee_leave_balance WHERE employee_id=? AND leave_type_id=? AND year=?",
        [$empId, $ltId, date('Y')]
    );
    if (!$bal || $bal['available'] < $days) apiError('Insufficient leave balance. Available: ' . ($bal['available'] ?? 0));

    // Check overlapping application
    $overlap = $db->fetchValue(
        "SELECT COUNT(*) FROM leave_applications WHERE employee_id=? AND status NOT IN ('rejected','cancelled')
         AND NOT (to_date < ? OR from_date > ?)",
        [$empId, $from, $to]
    );
    if ($overlap > 0) apiError('You already have a leave application for the selected dates');

    $id = $db->insertRow('leave_applications', [
        'employee_id'   => $empId,
        'leave_type_id' => $ltId,
        'from_date'     => $from,
        'to_date'       => $to,
        'days'          => $days,
        'is_half_day'   => $isHalf,
        'reason'        => $reason,
        'status'        => 'pending',
    ]);

    notifyReportingHead($db, $empId, 'New Leave Request',
        "{$authUser['first_name']} {$authUser['last_name']} applied for leave ($from to $to).",
        'leave', $id);

    apiSuccess(['application_id'=>$id,'days'=>$days], 'Leave application submitted successfully');
}

// ── LEAVE APPLICATIONS LIST ───────────────────────────────────
if ($action === 'leave_applications') {
    $status = sanitize($input['status'] ?? '');
    $where  = ['la.employee_id=?'];
    $params = [$empId];
    if ($status) { $where[] = 'la.status=?'; $params[] = $status; }

    $apps = $db->fetchAll(
        "SELECT la.*, la.from_date AS start_date, la.to_date AS end_date, la.created_at AS applied_on,
                lt.name AS lt_name, lt.code AS lt_code
           FROM leave_applications la
           JOIN leave_types lt ON lt.id=la.leave_type_id
          WHERE " . implode(' AND ',$where) . "
          ORDER BY la.created_at DESC LIMIT 50",
        $params
    );
    // 'data' must be a plain array to match the Android app's List<LeaveApplication> model
    apiSuccess($apps, 'Leave applications fetched');
}

// ── PAYSLIP ──────────────────────────────────────────────────
if ($action === 'payslip') {
    $month = (int)($input['month'] ?? date('n'));
    $year  = (int)($input['year']  ?? date('Y'));

    $payslip = $db->fetchOne(
        "SELECT pd.*, pr.status AS run_status
           FROM payroll_details pd
           JOIN payroll_runs pr ON pr.id=pd.payroll_run_id
          WHERE pd.employee_id=? AND pd.month=? AND pd.year=?",
        [$empId, $month, $year]
    );
    if (!$payslip) apiError('Payslip not yet generated for this month');

    $emp = $db->fetchOne("SELECT first_name,last_name,employee_id,pan_number FROM employees WHERE id=?", [$empId]);
    apiSuccess(['payslip'=>$payslip,'employee'=>$emp,'month'=>$month,'year'=>$year]);
}

// ── PAYSLIP LIST ──────────────────────────────────────────────
if ($action === 'payslip_list') {
    $list = $db->fetchAll(
        "SELECT pd.month, pd.year, pd.gross_salary, pd.net_salary, pd.total_deductions, pr.status
           FROM payroll_details pd
           JOIN payroll_runs pr ON pr.id=pd.payroll_run_id
          WHERE pd.employee_id=? ORDER BY pd.year DESC, pd.month DESC LIMIT 24",
        [$empId]
    );
    apiSuccess(['payslips'=>$list]);
}

// ── NOTICES ───────────────────────────────────────────────────
if ($action === 'notices') {
    // FIX (10 Jul 2026): this used to query a completely different,
    // disconnected table ('notices') than the one the web app actually
    // posts to ('notice_board') — so anything posted from the web
    // Notice Board never showed up in the app at all. Pointed this at
    // notice_board instead, with org scoping and column aliases matching
    // what the Android Notice model expects (id, title, category,
    // published_at).
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'company_id');
    $notices = $db->fetchAll(
        "SELECT id, title, category, content, created_at AS published_at
           FROM notice_board
          WHERE is_active=1 AND (expiry_date IS NULL OR expiry_date >= CURDATE()) AND $orgClause
          ORDER BY priority DESC, created_at DESC LIMIT 20",
        $orgParams
    );
    apiSuccess(['notices'=>$notices]);
}

// ── REGISTER DEVICE ──────────────────────────────────────────
if ($action === 'register_device') {
    $deviceId  = sanitize($input['device_id']  ?? '');
    $deviceName= sanitize($input['device_name'] ?? '');
    $platform  = sanitize($input['platform']    ?? 'android');
    $fcmToken  = sanitize($input['fcm_token']   ?? '');

    if (!$deviceId) apiError('Device ID required');

    $existing = $db->fetchValue("SELECT id FROM mobile_devices WHERE employee_id=? AND device_id=?", [$empId,$deviceId]);
    if ($existing) {
        $db->execute("UPDATE mobile_devices SET fcm_token=?,is_registered=1 WHERE employee_id=? AND device_id=?",
            [$fcmToken,$empId,$deviceId]);
    } else {
        $db->insertRow('mobile_devices', [
            'employee_id' => $empId,
            'device_id'   => $deviceId,
            'device_name' => $deviceName,
            'platform'    => $platform,
            'fcm_token'   => $fcmToken,
        ]);
    }
    apiSuccess([], 'Device registered');
}

// ── PERMISSIONS / COMP OFF ────────────────────────────────────
if ($action === 'apply_permission') {
    $date   = sanitize($input['date']   ?? date('Y-m-d'));
    $from   = sanitize($input['from_time'] ?? '');
    $to     = sanitize($input['to_time']   ?? '');
    $reason = sanitize($input['reason']    ?? '');
    if (!$from || !$to || !$reason) apiError('All fields required');
    $mins = round((strtotime($date.' '.$to) - strtotime($date.' '.$from)) / 60);
    if ($mins <= 0) apiError('Invalid time range');

    $id = $db->insertRow('permission_requests', [
        'employee_id'      => $empId,
        'permission_date'  => $date,
        'from_time'        => $from,
        'to_time'          => $to,
        'duration_minutes' => $mins,
        'reason'           => $reason,
    ]);

    notifyReportingHead($db, $empId, 'New Permission Request',
        "{$authUser['first_name']} {$authUser['last_name']} requested permission on $date ($from - $to).",
        'permission', $id);

    apiSuccess(['request_id'=>$id,'duration_minutes'=>$mins], 'Permission request submitted');
}

if ($action === 'my_permissions') {
    $perms = $db->fetchAll(
        "SELECT * FROM permission_requests WHERE employee_id=? ORDER BY created_at DESC LIMIT 20",
        [$empId]
    );
    // 'data' must be a plain array to match the Android app's List<PermissionRecord> model
    apiSuccess($perms, 'Permissions fetched');
}

// ── ON DUTY ──────────────────────────────────────────────────
// Requires the `on_duty_requests` table — see migration_on_duty.sql
if ($action === 'apply_on_duty') {
    $from      = sanitize($input['start_date'] ?? '');
    $to        = sanitize($input['end_date']   ?? '');
    $purpose   = sanitize($input['purpose']    ?? '');
    $location  = sanitize($input['location']   ?? '');
    $reason    = sanitize($input['reason']     ?? '');
    $isFullDay = !isset($input['is_full_day']) || in_array($input['is_full_day'], [true, 1, '1', 'true'], true);
    $fromTime  = $isFullDay ? null : sanitize($input['from_time'] ?? '');
    $toTime    = $isFullDay ? null : sanitize($input['to_time']   ?? '');

    if (!$from || !$to || !$purpose) apiError('From date, to date and purpose are required');
    if (strtotime($to) < strtotime($from)) apiError('To date cannot be before from date');
    if (!$isFullDay && $from !== $to) apiError('A part-day On Duty request must be for a single date');
    if (!$isFullDay && (!$fromTime || !$toTime)) apiError('Please provide both start and end time for a part-day request');
    if (!$isFullDay && strtotime($toTime) <= strtotime($fromTime)) apiError('End time must be after start time');
    $days = max(1, ((strtotime($to) - strtotime($from)) / 86400) + 1);

    $overlap = $db->fetchValue(
        "SELECT COUNT(*) FROM on_duty_requests WHERE employee_id=? AND status NOT IN ('rejected','cancelled')
         AND NOT (to_date < ? OR from_date > ?)",
        [$empId, $from, $to]
    );
    if ($overlap > 0) apiError('You already have an On Duty request for the selected dates');

    $id = $db->insertRow('on_duty_requests', [
        'employee_id' => $empId,
        'from_date'   => $from,
        'to_date'     => $to,
        'is_full_day' => $isFullDay ? 1 : 0,
        'from_time'   => $fromTime,
        'to_time'     => $toTime,
        'days'        => $days,
        'purpose'     => $purpose,
        'location'    => $location,
        'reason'      => $reason,
        'status'      => 'pending',
    ]);

    $when = $isFullDay ? "$from to $to" : "$from, $fromTime–$toTime";
    notifyReportingHead($db, $empId, 'New On Duty Request',
        "{$authUser['first_name']} {$authUser['last_name']} requested On Duty ($when) — $purpose.",
        'on_duty', $id);

    apiSuccess(['request_id'=>$id,'days'=>$days], 'On Duty request submitted successfully');
}

if ($action === 'my_on_duty') {
    $status = sanitize($input['status'] ?? '');
    $where  = ['employee_id=?'];
    $params = [$empId];
    if ($status) { $where[] = 'status=?'; $params[] = $status; }

    $reqs = $db->fetchAll(
        "SELECT *, from_date AS start_date, to_date AS end_date, created_at AS applied_on
           FROM on_duty_requests WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT 50",
        $params
    );
    apiSuccess($reqs, 'On Duty requests fetched');
}

// ── LATE REGULARIZATION ─────────────────────────────────────────
// Requires the `late_regularizations` table — see mobile_app/migration_late_regularizations.sql
if ($action === 'apply_late_regularization') {
    $date   = sanitize($input['date']   ?? date('Y-m-d'));
    $reason = sanitize($input['reason'] ?? '');
    if (!$reason) apiError('Reason is required');

    // Compute late minutes from today's muster record, if available
    $muster = $db->fetchOne(
        "SELECT in_time, late_minutes FROM muster WHERE employee_id=? AND muster_date=?",
        [$empId, $date]
    );
    $lateMinutes = (int)($muster['late_minutes'] ?? 0);

    $existing = $db->fetchOne(
        "SELECT id FROM late_regularizations WHERE employee_id=? AND date=?",
        [$empId, $date]
    );
    if ($existing) {
        apiError('A late regularization request already exists for this date');
    }

    $id = $db->insertRow('late_regularizations', [
        'employee_id'  => $empId,
        'date'         => $date,
        'late_minutes' => $lateMinutes,
        'reason'       => $reason,
    ]);

    notifyReportingHead($db, $empId, 'New Late Mark Request',
        "{$authUser['first_name']} {$authUser['last_name']} requested late-mark regularization for $date.",
        'late', $id);

    apiSuccess(['request_id' => $id], 'Late regularization request submitted');
}

if ($action === 'my_late_regularizations') {
    $records = $db->fetchAll(
        "SELECT * FROM late_regularizations WHERE employee_id=? ORDER BY created_at DESC LIMIT 20",
        [$empId]
    );
    // 'data' must be a plain array to match the Android app's List<LateMarkRecord> model
    apiSuccess($records, 'Late regularizations fetched');
}

// ── MUSTER MODIFICATION ─────────────────────────────────────────
// For missed sign-in / sign-out due to forgetfulness, system, or network issues.
// Requires the `muster_modification_requests` table with a `time` column —
// see migration_muster_modification.sql
if ($action === 'apply_muster_modification') {
    $date      = sanitize($input['date']       ?? date('Y-m-d'));
    $time      = sanitize($input['time']       ?? '');
    $issueType = sanitize($input['issue_type'] ?? '');
    $reason    = sanitize($input['reason']     ?? '');

    $validTypes = ['forgot_signin', 'forgot_signout', 'network_issue', 'system_problem', 'others'];
    if (!in_array($issueType, $validTypes, true)) apiError('Invalid issue type');
    if (!$time) apiError('Please select the time this happened');
    if ($issueType === 'others' && !$reason) apiError('Please describe the issue');

    $id = $db->insertRow('muster_modification_requests', [
        'employee_id' => $empId,
        'date'        => $date,
        'time'        => $time,
        'issue_type'  => $issueType,
        'reason'      => $reason,
    ]);

    notifyReportingHead($db, $empId, 'New Muster Modification Request',
        "{$authUser['first_name']} {$authUser['last_name']} requested a muster correction for $date.",
        'muster', $id);

    apiSuccess(['request_id' => $id], 'Muster modification request submitted');
}

if ($action === 'my_muster_modifications') {
    $records = $db->fetchAll(
        "SELECT * FROM muster_modification_requests WHERE employee_id=? ORDER BY created_at DESC LIMIT 20",
        [$empId]
    );
    apiSuccess($records, 'Muster modification requests fetched');
}

// ── MUSTER MODIFICATION — Manager/HR approval ───────────────────
if ($action === 'pending_muster_modifications') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'], true)) apiError('Not authorized', 403);
    // FIX (10 Jul 2026): this had NO scoping at all — any manager/HR
    // role saw every organization's pending muster requests.
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $rows = $db->fetchAll(
        "SELECT mmr.*, e.employee_id AS emp_code, e.first_name, e.last_name
           FROM muster_modification_requests mmr
           JOIN employees e ON e.id = mmr.employee_id
          WHERE mmr.status='pending' AND $orgClause
          ORDER BY mmr.created_at ASC LIMIT 50",
        $orgParams
    );
    apiSuccess($rows, 'Pending muster modification requests fetched');
}

if ($action === 'approve_muster_modification') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'], true)) apiError('Not authorized', 403);
    $reqId  = (int)($input['request_id'] ?? 0);
    $decision = sanitize($input['decision'] ?? ''); // 'approved' | 'rejected'
    $remarks  = sanitize($input['remarks']  ?? '');
    if (!$reqId || !in_array($decision, ['approved','rejected'], true)) apiError('Invalid request');

    // FIX (10 Jul 2026): confirm this request belongs to an employee in
    // the current organization before approving/rejecting it.
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $req = $db->fetchOne(
        "SELECT mmr.* FROM muster_modification_requests mmr JOIN employees e ON e.id=mmr.employee_id WHERE mmr.id=? AND $orgClause",
        array_merge([$reqId], $orgParams)
    );
    if (!$req) apiError('Request not found', 404);
    if ($req['status'] !== 'pending') apiError('This request has already been processed');

    $db->execute(
        "UPDATE muster_modification_requests SET status=?, approved_by=?, approved_at=NOW(), remarks=? WHERE id=?",
        [$decision, $empId, $remarks, $reqId]
    );

    // On approval, capture the corrected time into the muster (attendance) table
    if ($decision === 'approved') {
        $targetEmp = (int)$req['employee_id'];
        $date      = $req['date'];
        $time      = $req['time'];

        $muster = $db->fetchOne("SELECT * FROM muster WHERE employee_id=? AND muster_date=?", [$targetEmp, $date]);

        if ($req['issue_type'] === 'forgot_signin') {
            if ($muster) {
                $db->execute("UPDATE muster SET in_time=?, status='P' WHERE employee_id=? AND muster_date=?",
                    [$time, $targetEmp, $date]);
            } else {
                $db->insertRow('muster', ['employee_id'=>$targetEmp,'muster_date'=>$date,'in_time'=>$time,'status'=>'P']);
            }
        } elseif ($req['issue_type'] === 'forgot_signout') {
            if ($muster && $muster['in_time']) {
                $workHours = round((strtotime($date.' '.$time) - strtotime($date.' '.$muster['in_time'])) / 3600, 2);
                $status    = $workHours >= 7 ? 'P' : ($workHours >= 4 ? 'H' : 'P');
                $db->execute("UPDATE muster SET out_time=?, work_hours=?, status=? WHERE employee_id=? AND muster_date=?",
                    [$time, $workHours, $status, $targetEmp, $date]);
            } elseif ($muster) {
                $db->execute("UPDATE muster SET out_time=? WHERE employee_id=? AND muster_date=?", [$time, $targetEmp, $date]);
            } else {
                $db->insertRow('muster', ['employee_id'=>$targetEmp,'muster_date'=>$date,'out_time'=>$time,'status'=>'P']);
            }
        } else {
            // network_issue / system_problem / others — best effort: fill whichever
            // side (in/out) is still missing for that date.
            if (!$muster) {
                $db->insertRow('muster', ['employee_id'=>$targetEmp,'muster_date'=>$date,'in_time'=>$time,'status'=>'P']);
            } elseif (!$muster['in_time']) {
                $db->execute("UPDATE muster SET in_time=?, status='P' WHERE employee_id=? AND muster_date=?",
                    [$time, $targetEmp, $date]);
            } elseif (!$muster['out_time']) {
                $workHours = round((strtotime($date.' '.$time) - strtotime($date.' '.$muster['in_time'])) / 3600, 2);
                $db->execute("UPDATE muster SET out_time=?, work_hours=? WHERE employee_id=? AND muster_date=?",
                    [$time, $workHours, $targetEmp, $date]);
            }
            // if both already set, no attendance change — approval just records the note
        }
    }

    notify($db, $req['employee_id'], 'Muster Modification ' . ucfirst($decision),
        "Your muster correction request for {$req['date']} was $decision." . ($remarks ? " Remarks: $remarks" : ''),
        'muster', $reqId);

    apiSuccess([], 'Request ' . $decision);
}

// ── TIMESHEET ─────────────────────────────────────────────────
if ($action === 'submit_timesheet') {
    $weekStart = sanitize($input['week_start'] ?? '');
    $weekEnd   = sanitize($input['week_end']   ?? '');
    $entries   = $input['entries'] ?? [];
    if (!$weekStart || !$weekEnd || !$entries) apiError('Week dates and entries required');

    $total = array_sum(array_column($entries, 'hours'));
    $db->beginTransaction();
    try {
        $tsId = $db->insertRow('timesheets', [
            'employee_id' => $empId,
            'week_start'  => $weekStart,
            'week_end'    => $weekEnd,
            'total_hours' => $total,
            'status'      => 'submitted',
            'submitted_at'=> date('Y-m-d H:i:s'),
        ]);
        foreach ($entries as $e) {
            $db->insertRow('timesheet_entries', [
                'timesheet_id' => $tsId,
                'entry_date'   => sanitize($e['date'] ?? ''),
                'project'      => sanitize($e['project'] ?? ''),
                'task'         => sanitize($e['task'] ?? ''),
                'hours'        => (float)($e['hours'] ?? 0),
                'description'  => sanitize($e['description'] ?? ''),
            ]);
        }
        $db->commit();
        apiSuccess(['timesheet_id'=>$tsId,'total_hours'=>$total], 'Timesheet submitted');
    } catch (Exception $ex) {
        $db->rollback();
        apiError('Error: ' . $ex->getMessage());
    }
}

// ── HOLIDAYS ─────────────────────────────────────────────────
if ($action === 'holidays') {
    $year = (int)($input['year'] ?? date('Y'));
    $holidays = $db->fetchAll(
        "SELECT name, holiday_date AS date, is_national FROM holiday_calendar WHERE YEAR(holiday_date)=? AND is_active=1 ORDER BY holiday_date",
        [$year]
    );
    // 'data' must be a plain array to match the Android app's List<Holiday> model
    apiSuccess($holidays, 'Holidays fetched');
}

// ── ADVANCE APPLICATION ───────────────────────────────────────
if ($action === 'apply_advance') {
    $headId    = (int)($input['advance_head_id'] ?? 0);
    $amount    = (float)($input['amount'] ?? 0);
    $installments= (int)($input['installments'] ?? 1);
    $purpose   = sanitize($input['purpose'] ?? '');

    if (!$headId || $amount <= 0 || !$purpose) apiError('All fields required');

    $head = $db->fetchOne("SELECT * FROM advance_heads WHERE id=? AND is_active=1", [$headId]);
    if (!$head) apiError('Invalid advance type');
    if ($head['max_amount'] > 0 && $amount > $head['max_amount']) apiError('Amount exceeds limit of ' . money($head['max_amount']));

    // Interest calculation
    $interest  = $head['interest_rate'] > 0 ? ($amount * $head['interest_rate'] / 100 / 12 * $installments) : 0;
    $totalRep  = $amount + $interest;
    $monthlyEMI= round($totalRep / $installments, 2);

    $id = $db->insertRow('advance_applications', [
        'employee_id'      => $empId,
        'advance_head_id'  => $headId,
        'application_date' => date('Y-m-d'),
        'amount_requested' => $amount,
        'installments'     => $installments,
        'interest_rate'    => $head['interest_rate'],
        'total_repayable'  => $totalRep,
        'monthly_installment'=> $monthlyEMI,
        'purpose'          => $purpose,
        'status'           => 'pending',
    ]);

    apiSuccess([
        'application_id'    => $id,
        'interest'          => $interest,
        'total_repayable'   => $totalRep,
        'monthly_installment'=> $monthlyEMI,
    ], 'Advance application submitted');
}

// ── ADVANCE LIST ──────────────────────────────────────────────
if ($action === 'my_advances') {
    $advances = $db->fetchAll(
        "SELECT aa.*, ah.name AS head_name FROM advance_applications aa
          JOIN advance_heads ah ON ah.id=aa.advance_head_id
         WHERE aa.employee_id=? ORDER BY aa.created_at DESC",
        [$empId]
    );
    apiSuccess(['advances'=>$advances]);
}

// ── TEAM ATTENDANCE (Manager) ────────────────────────────────
if ($action === 'team_attendance') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $date = sanitize($input['date'] ?? date('Y-m-d'));

    // FIX (06 Jul 2026): this used to return apiSuccess(['team'=>..,'date'=>..]),
    // an OBJECT — but the Android app's getTeamAttendance() expects
    // Response<ApiResponse<List<TeamAttendanceSummary>>>, a plain ARRAY.
    // That mismatch is what threw "Expected BEGIN_ARRAY but was
    // BEGIN_OBJECT" on the Team screen. Also aliased in_time/out_time to
    // check_in/check_out (added emp_code) since TeamAttendanceSummary
    // requires those exact key names, and translated the muster table's
    // single-letter status codes (P/A/H/L/WO/PH/CO/LOP) into the words
    // ReportsFragment.kt actually compares against ("present"/"late"/"absent") —
    // without this the Present/Absent counts on that screen always read 0.
    // FIX (10 Jul 2026): added org scope (defense-in-depth) and let
    // HR/management see the whole org's team, not just direct reports —
    // consistent with pending_approvals' pattern.
    $isManagementRole = in_array($authUser['role'], ['hr','management','admin','superadmin']);
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $whereReport = $isManagementRole ? $orgClause : "e.reporting_manager_id=? AND $orgClause";
    $teamParams  = $isManagementRole ? array_merge([$date], $orgParams) : array_merge([$date], [$empId], $orgParams);
    $team = $db->fetchAll(
        "SELECT e.id, e.employee_id AS emp_code, e.first_name, e.last_name,
                CASE
                    WHEN m.status='P' AND m.is_late=1 THEN 'late'
                    WHEN m.status='P' THEN 'present'
                    WHEN m.status='A' OR m.status IS NULL THEN 'absent'
                    WHEN m.status='H' THEN 'half_day'
                    WHEN m.status='L' THEN 'leave'
                    WHEN m.status='WO' THEN 'week_off'
                    WHEN m.status='PH' THEN 'holiday'
                    WHEN m.status='CO' THEN 'comp_off'
                    WHEN m.status='LOP' THEN 'lop'
                    WHEN m.status='OD' THEN 'on_duty'
                    ELSE 'absent'
                END AS status,
                m.in_time AS check_in, m.out_time AS check_out, m.work_hours
           FROM employees e
      LEFT JOIN muster m ON m.employee_id=e.id AND m.muster_date=?
          WHERE $whereReport AND e.status='active'
          ORDER BY e.first_name",
        $teamParams
    );
    apiSuccess($team, 'Team attendance fetched');
}

// ── APPROVE LEAVE (Manager) ───────────────────────────────────
if ($action === 'approve_leave') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $appId  = (int)($input['application_id'] ?? 0);
    $status = sanitize($input['status'] ?? '');
    $remarks= sanitize($input['remarks'] ?? '');
    if (!$appId || !in_array($status, ['approved','rejected'])) apiError('Invalid request');

    // FIX (10 Jul 2026): confirm this application belongs to an employee
    // in the current organization before approving/rejecting it.
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $app = $db->fetchOne(
        "SELECT la.* FROM leave_applications la JOIN employees e ON e.id=la.employee_id WHERE la.id=? AND $orgClause",
        array_merge([$appId], $orgParams)
    );
    if (!$app) apiError('Application not found');
    if ($app['status'] !== 'pending') apiError('Application already processed');

    $db->execute(
        "UPDATE leave_applications SET status=?,approved_by=?,approved_at=NOW(),remarks=? WHERE id=?",
        [$status, $empId, $remarks, $appId]
    );

    if ($status === 'approved') {
        $db->execute(
            "INSERT INTO employee_leave_balance (employee_id,leave_type_id,year,allocated,used)
             VALUES (?,?,?,0,?) ON DUPLICATE KEY UPDATE used=used+?",
            [$app['employee_id'],$app['leave_type_id'],date('Y'),$app['days'],$app['days']]
        );
    }
    notify($db, $app['employee_id'], 'Leave Request ' . ucfirst($status),
        "Your leave request ({$app['from_date']} to {$app['to_date']}) was $status." . ($remarks ? " Remarks: $remarks" : ''),
        'leave', $appId);
    apiSuccess(['status'=>$status], 'Leave application ' . $status);
}

// ── APPROVE PERMISSION (Manager) ─────────────────────────────
if ($action === 'approve_permission') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $reqId  = (int)($input['request_id'] ?? 0);
    $status = sanitize($input['status'] ?? '');
    $remarks= sanitize($input['remarks'] ?? '');
    if (!$reqId || !in_array($status, ['approved','rejected'])) apiError('Invalid request');

    // FIX (10 Jul 2026): confirm this request belongs to an employee in
    // the current organization before approving/rejecting it.
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $req = $db->fetchOne(
        "SELECT pr.* FROM permission_requests pr JOIN employees e ON e.id=pr.employee_id WHERE pr.id=? AND $orgClause",
        array_merge([$reqId], $orgParams)
    );
    if (!$req) apiError('Request not found');
    if ($req['status'] !== 'pending') apiError('Request already processed');

    $db->execute(
        "UPDATE permission_requests SET status=?,approved_by=?,approved_at=NOW(),remarks=? WHERE id=?",
        [$status, $empId, $remarks, $reqId]
    );
    notify($db, $req['employee_id'], 'Permission Request ' . ucfirst($status),
        "Your permission request for {$req['permission_date']} was $status." . ($remarks ? " Remarks: $remarks" : ''),
        'permission', $reqId);
    apiSuccess(['status'=>$status], 'Permission request ' . $status);
}

// ── APPROVE ON DUTY (Manager) ────────────────────────────────
if ($action === 'approve_on_duty') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $reqId  = (int)($input['request_id'] ?? 0);
    $status = sanitize($input['status'] ?? '');
    $remarks= sanitize($input['remarks'] ?? '');
    if (!$reqId || !in_array($status, ['approved','rejected'])) apiError('Invalid request');

    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $req = $db->fetchOne(
        "SELECT od.* FROM on_duty_requests od JOIN employees e ON e.id=od.employee_id WHERE od.id=? AND $orgClause",
        array_merge([$reqId], $orgParams)
    );
    if (!$req) apiError('Request not found');
    if ($req['status'] !== 'pending') apiError('Request already processed');

    $db->execute(
        "UPDATE on_duty_requests SET status=?,approved_by=?,approved_at=NOW(),remarks=? WHERE id=?",
        [$status, $empId, $remarks, $reqId]
    );

    // On approval, mark each day of the range 'OD' in the muster (attendance)
    // table — same behaviour as the web app's modules/attendance/onduty.php —
    // so the employee doesn't show as absent while away on official work.
    // Real punch times (if they signed in at office before/after) are never
    // overwritten — only a day with no punch at all gets the on-duty
    // from_time/to_time filled in as a record of the off-site hours.
    if ($status === 'approved') {
        $targetEmp = (int)$req['employee_id'];
        $cursor = strtotime($req['from_date']);
        $end    = strtotime($req['to_date']);
        while ($cursor <= $end) {
            $d = date('Y-m-d', $cursor);
            $existing = $db->fetchOne("SELECT id, in_time, out_time FROM muster WHERE employee_id=? AND muster_date=?", [$targetEmp, $d]);
            if ($existing) {
                $set = ['status' => 'OD'];
                if (!$existing['in_time']  && $req['from_time']) $set['in_time']  = $req['from_time'];
                if (!$existing['out_time'] && $req['to_time'])   $set['out_time'] = $req['to_time'];
                $db->updateRow('muster', $set, $existing['id']);
            } else {
                $db->insertRow('muster', [
                    'employee_id' => $targetEmp,
                    'muster_date' => $d,
                    'status'      => 'OD',
                    'in_time'     => $req['from_time'],
                    'out_time'    => $req['to_time'],
                ]);
            }
            $cursor = strtotime('+1 day', $cursor);
        }
    }

    notify($db, $req['employee_id'], 'On Duty Request ' . ucfirst($status),
        "Your On Duty request ({$req['from_date']} to {$req['to_date']}) was $status." . ($remarks ? " Remarks: $remarks" : ''),
        'on_duty', $reqId);
    apiSuccess(['status'=>$status], 'On Duty request ' . $status);
}

// ── APPROVE LATE MARK (Manager) ──────────────────────────────
if ($action === 'approve_late_mark') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $lateId = (int)($input['late_id'] ?? 0);
    $status = sanitize($input['status'] ?? '');
    $remarks= sanitize($input['remarks'] ?? '');
    if (!$lateId || !in_array($status, ['approved','rejected'])) apiError('Invalid request');

    // FIX (10 Jul 2026): confirm this request belongs to an employee in
    // the current organization before approving/rejecting it.
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $late = $db->fetchOne(
        "SELECT lr.* FROM late_regularizations lr JOIN employees e ON e.id=lr.employee_id WHERE lr.id=? AND $orgClause",
        array_merge([$lateId], $orgParams)
    );
    if (!$late) apiError('Request not found');
    if ($late['status'] !== 'pending') apiError('Request already processed');

    $db->execute(
        "UPDATE late_regularizations SET status=?,approved_by=?,approved_at=NOW(),remarks=? WHERE id=?",
        [$status, $empId, $remarks, $lateId]
    );
    notify($db, $late['employee_id'], 'Late Mark Request ' . ucfirst($status),
        "Your late-mark request for {$late['date']} was $status." . ($remarks ? " Remarks: $remarks" : ''),
        'late', $lateId);
    apiSuccess(['status'=>$status], 'Late mark request ' . $status);
}

// ── PENDING APPROVALS (Manager) ──────────────────────────────
if ($action === 'pending_approvals') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    // Managers see only their direct reports. HR/Admin/Superadmin ("Management")
    // additionally see anyone with NO specific reporting manager set — i.e.
    // employees who report directly to Management rather than a named person.
    // FIX (10 Jul 2026): the "Management" branch used to match across
    // EVERY organization's employees with no reporting manager, not just
    // the current org's — scoped now.
    $isManagementRole = in_array($authUser['role'], ['hr','management','admin','superadmin']);
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $whereReport = $isManagementRole
        ? "(e.reporting_manager_id=? OR e.reporting_manager_id IS NULL) AND $orgClause"
        : "e.reporting_manager_id=? AND $orgClause";
    $pending = $db->fetchAll(
        "SELECT la.*, e.first_name, e.last_name, e.employee_id AS emp_code, lt.name AS lt_name
           FROM leave_applications la
           JOIN employees e ON e.id=la.employee_id
           JOIN leave_types lt ON lt.id=la.leave_type_id
          WHERE la.status='pending' AND $whereReport
          ORDER BY la.created_at DESC",
        array_merge([$empId], $orgParams)
    );
    // 'data' must be a plain array to match the Android app's List<LeaveApplication> model
    apiSuccess($pending, 'Pending leave approvals fetched');
}

// ── PENDING PERMISSIONS (Manager) ────────────────────────────
if ($action === 'pending_permissions') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $isManagementRole = in_array($authUser['role'], ['hr','management','admin','superadmin']);
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $whereReport = $isManagementRole
        ? "(e.reporting_manager_id=? OR e.reporting_manager_id IS NULL) AND $orgClause"
        : "e.reporting_manager_id=? AND $orgClause";
    $pending = $db->fetchAll(
        "SELECT pr.*, e.first_name, e.last_name, e.employee_id AS emp_code
           FROM permission_requests pr
           JOIN employees e ON e.id=pr.employee_id
          WHERE pr.status='pending' AND $whereReport
          ORDER BY pr.created_at DESC",
        array_merge([$empId], $orgParams)
    );
    apiSuccess($pending, 'Pending permissions fetched');
}

// ── PENDING ON DUTY (Manager) ────────────────────────────────
if ($action === 'pending_on_duty') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $isManagementRole = in_array($authUser['role'], ['hr','management','admin','superadmin']);
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $whereReport = $isManagementRole
        ? "(e.reporting_manager_id=? OR e.reporting_manager_id IS NULL) AND $orgClause"
        : "e.reporting_manager_id=? AND $orgClause";
    $pending = $db->fetchAll(
        "SELECT od.*, e.first_name, e.last_name, e.employee_id AS emp_code
           FROM on_duty_requests od
           JOIN employees e ON e.id=od.employee_id
          WHERE od.status='pending' AND $whereReport
          ORDER BY od.created_at DESC",
        array_merge([$empId], $orgParams)
    );
    apiSuccess($pending, 'Pending On Duty requests fetched');
}

// ── PENDING LATE MARKS (Manager) ─────────────────────────────
if ($action === 'pending_late_marks') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $isManagementRole = in_array($authUser['role'], ['hr','management','admin','superadmin']);
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $whereReport = $isManagementRole
        ? "(e.reporting_manager_id=? OR e.reporting_manager_id IS NULL) AND $orgClause"
        : "e.reporting_manager_id=? AND $orgClause";
    $pending = $db->fetchAll(
        "SELECT lr.*, e.first_name, e.last_name, e.employee_id AS emp_code
           FROM late_regularizations lr
           JOIN employees e ON e.id=lr.employee_id
          WHERE lr.status='pending' AND $whereReport
          ORDER BY lr.created_at DESC",
        array_merge([$empId], $orgParams)
    );
    apiSuccess($pending, 'Pending late marks fetched');
}

// ── PENDING COUNTS (Manager badge) ───────────────────────────
// This is what MainActivity.kt polls every 30s to badge the "More" nav
// item — the endpoint didn't exist at all before, so every poll was
// silently failing (caught in a try/catch) and the badge never showed
// anything for anyone. 'muster' covers on-the-fly attendance corrections
// (that flow isn't wired up server-side yet, so it always reports 0 rather
// than querying a table that doesn't exist).
if ($action === 'pending_counts') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) {
        apiSuccess(['leave'=>0,'permission'=>0,'late'=>0,'muster'=>0,'on_duty'=>0,'total'=>0]);
    }
    $isManagementRole = in_array($authUser['role'], ['hr','management','admin','superadmin']);
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $whereReport = $isManagementRole
        ? "(e.reporting_manager_id=? OR e.reporting_manager_id IS NULL) AND $orgClause"
        : "e.reporting_manager_id=? AND $orgClause";
    $params = array_merge([$empId], $orgParams);

    $leave      = (int)$db->fetchValue("SELECT COUNT(*) FROM leave_applications la JOIN employees e ON e.id=la.employee_id WHERE la.status='pending' AND $whereReport", $params);
    $permission = (int)$db->fetchValue("SELECT COUNT(*) FROM permission_requests pr JOIN employees e ON e.id=pr.employee_id WHERE pr.status='pending' AND $whereReport", $params);
    $late       = (int)$db->fetchValue("SELECT COUNT(*) FROM late_regularizations lr JOIN employees e ON e.id=lr.employee_id WHERE lr.status='pending' AND $whereReport", $params);
    $onDuty     = (int)$db->fetchValue("SELECT COUNT(*) FROM on_duty_requests od JOIN employees e ON e.id=od.employee_id WHERE od.status='pending' AND $whereReport", $params);
    $muster     = 0; // see note above

    apiSuccess([
        'leave'      => $leave,
        'permission' => $permission,
        'late'       => $late,
        'muster'     => $muster,
        'on_duty'    => $onDuty,
        'total'      => $leave + $permission + $late + $muster + $onDuty,
    ]);
}

// ── INVESTMENT DECLARATION ────────────────────────────────────
if ($action === 'submit_investment') {
    $fy      = sanitize($input['financial_year'] ?? FINANCIAL_YEAR);
    $headId  = (int)($input['tds_head_id'] ?? 0);
    $amount  = (float)($input['declared_amount'] ?? 0);
    if (!$headId || $amount <= 0) apiError('Head and amount required');

    $existing = $db->fetchOne(
        "SELECT id FROM investment_declarations WHERE employee_id=? AND financial_year=? AND tds_head_id=?",
        [$empId,$fy,$headId]
    );
    if ($existing) {
        $db->execute("UPDATE investment_declarations SET declared_amount=?,status='submitted' WHERE id=?", [$amount,$existing['id']]);
        apiSuccess([], 'Declaration updated');
    } else {
        $id = $db->insertRow('investment_declarations', [
            'employee_id'     => $empId,
            'financial_year'  => $fy,
            'tds_head_id'     => $headId,
            'declared_amount' => $amount,
            'status'          => 'submitted',
        ]);
        apiSuccess(['declaration_id'=>$id], 'Declaration submitted');
    }
}

if ($action === 'my_declarations') {
    $fy   = sanitize($input['financial_year'] ?? FINANCIAL_YEAR);
    $decl = $db->fetchAll(
        "SELECT id2.*, th.section, th.name AS head_name, th.max_limit
           FROM investment_declarations id2
           JOIN tds_heads th ON th.id=id2.tds_head_id
          WHERE id2.employee_id=? AND id2.financial_year=?",
        [$empId, $fy]
    );
    apiSuccess(['declarations'=>$decl,'financial_year'=>$fy]);
}

// ── EXPENSE CLAIM ─────────────────────────────────────────────
if ($action === 'submit_expense') {
    $typeId  = (int)($input['expense_type_id'] ?? 0);
    $date    = sanitize($input['expense_date'] ?? date('Y-m-d'));
    $amount  = (float)($input['amount'] ?? 0);
    $desc    = sanitize($input['description'] ?? '');
    if (!$typeId || $amount <= 0 || !$desc) apiError('All fields required');

    $id = $db->insertRow('expense_claims', [
        'employee_id'     => $empId,
        'expense_type_id' => $typeId,
        'expense_date'    => $date,
        'amount'          => $amount,
        'description'     => $desc,
        'status'          => 'pending',
    ]);
    apiSuccess(['expense_id'=>$id], 'Expense claim submitted');
}

// ── TEAM REPORT (Monthly Report screen) ────────────────────────
// FIX (06 Jul 2026): this action didn't exist at all on the server —
// ApiService.kt's getTeamReport() was calling ?action=team_report and
// getting the generic "Unknown action" 404 fallback. Added now, using
// the precomputed muster_summary table. 'data' is a plain array to
// match List<Map<String, Any>> in ReportsFragment.kt.
if ($action === 'team_report') {
    if (!in_array($authUser['role'], ['manager','supervisor','hod','hr','management','admin','superadmin'])) apiError('Access denied', 403);
    $month = (int)($input['month'] ?? date('n'));
    $year  = (int)($input['year']  ?? date('Y'));

    // FIX (10 Jul 2026): the management branch had NO org scope at all
    // — "e.status='active'" matched every organization's employees.
    $isManagementRole = in_array($authUser['role'], ['hr','management','admin','superadmin']);
    [$orgClause, $orgParams] = apiOrgScopeClause($db, $authUser, 'e.company_id');
    $where  = $isManagementRole ? "e.status='active' AND $orgClause" : "e.status='active' AND e.reporting_manager_id=? AND $orgClause";
    $params = $isManagementRole ? array_merge([$month, $year], $orgParams) : array_merge([$month, $year], [$empId], $orgParams);

    $report = $db->fetchAll(
        "SELECT e.employee_id AS emp_code, e.first_name, e.last_name,
                COALESCE(ms.present_days,0) AS present,
                COALESCE(ms.absent_days,0)  AS absent,
                COALESCE(ms.late_count,0)   AS late
           FROM employees e
      LEFT JOIN muster_summary ms ON ms.employee_id=e.id AND ms.month=? AND ms.year=?
          WHERE $where
          ORDER BY e.first_name",
        $params
    );
    apiSuccess($report, 'Team report fetched');
}

if ($action === 'my_expenses') {
    $expenses = $db->fetchAll(
        "SELECT ec.*, et.name AS type_name FROM expense_claims ec
          JOIN expense_types et ON et.id=ec.expense_type_id
         WHERE ec.employee_id=? ORDER BY ec.created_at DESC LIMIT 30",
        [$empId]
    );
    apiSuccess(['expenses'=>$expenses]);
}

// Default – unknown action
apiError('Unknown action: ' . $action, 404);
