<?php
// admin/api.php  –  All admin CRUD operations (JSON)
//
// This version adds bilingual (English + Arabic) support. Every text field
// now has a matching _ar field, accepted from the request body and saved
// into the corresponding _ar database column. Arabic fields are OPTIONAL:
// if the admin leaves them blank, the column is saved as NULL and the
// customer menu falls back to the English value at render time.

require_once __DIR__ . '/auth.php';
require_login();

header('Content-Type: application/json');
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $_GET['action'] ?? '';

// ── PERMISSION GATE ──────────────────────────────────────────
// Every admin action is mapped to the section permission it
// requires. A super admin passes everything. Anyone else must
// have the matching perm_* flag (loaded into the session at
// login). Actions not in the map require only a valid login
// (e.g. change_password — any user may change their own).
$ACTION_PERMISSIONS = [
    // Menu section
    'list_categories'     => 'menu',
    'save_category'       => 'menu',
    'delete_category'     => 'menu',
    'list_items'          => 'menu',
    'save_item'           => 'menu',
    'delete_item'         => 'menu',
    'set_item_visibility' => 'menu',
    'bulk_set_visibility' => 'menu',
    'list_origins'        => 'menu',
    'save_origin'         => 'menu',
    'delete_origin'       => 'menu',
    'list_uploads'        => 'menu',
    'upload_photo'        => 'menu',
    'save_sort_orders'    => 'menu',
    // Orders section
    'list_orders'         => 'orders',
    'get_order'           => 'orders',
    'update_order_status' => 'orders',
    // Settings section
    'save_settings'       => 'settings',
    'get_settings'        => 'settings',
    'test_email'          => 'settings',
    'test_payment_connection' => 'settings',
    // User management — super admin only
    'list_users'          => 'users',
    'save_user'           => 'users',
    'delete_user'         => 'users',
    // Branch management — save/delete are super admin only.
    // list_branches is intentionally NOT in this map: any logged-in
    // admin may read the branch list (the user-management modal and
    // item editor need it to populate dropdowns).
    'save_branch'         => 'users',
    'delete_branch'       => 'users',
];
if (isset($ACTION_PERMISSIONS[$action])) {
    // require_permission emits a 403 JSON response and exits if the
    // logged-in user lacks the needed section permission.
    require_permission($ACTION_PERMISSIONS[$action]);
}

// Small helper: trim a field, or return null if it's empty/missing.
// We use NULL in the DB for missing Arabic translations so the public
// menu API can detect "no translation yet" vs "empty on purpose".
function nullable_trim(?string $v): ?string {
    $v = trim((string)($v ?? ''));
    return $v === '' ? null : $v;
}

try {
    // ── CATEGORIES ────────────────────────────────────────────
    // ── COUNTRY-OF-ORIGIN MASTER DATA ────────────────────────────
    // CRUD for the small table that backs the "Country of Origin"
    // disclosure shown on the web menu (header link → modal) and
    // on the last page of the printable menu. Admin manages these
    // rows via the "Origins" sidebar nav item.
    if ($action === 'list_origins') {
        json_out(db()->query(
            'SELECT id, item_en, item_ar, country_en, country_ar, sort_order
             FROM item_origins ORDER BY sort_order, id'
        )->fetchAll());
    }

    if ($action === 'save_origin') {
        $id        = intval($body['id'] ?? 0);
        $item_en   = trim($body['item_en']    ?? '');
        $item_ar   = trim($body['item_ar']    ?? '');
        $country_en = trim($body['country_en'] ?? '');
        $country_ar = trim($body['country_ar'] ?? '');
        $sort      = intval($body['sort_order'] ?? 0);

        // All four fields required — partial origin rows would
        // confuse customers more than help. Reject the save.
        if (!$item_en || !$item_ar || !$country_en || !$country_ar) {
            json_out(['error' => 'All four fields required (item EN/AR + country EN/AR)'], 422);
        }

        if ($id) {
            $stmt = db()->prepare(
                'UPDATE item_origins
                 SET item_en=?, item_ar=?, country_en=?, country_ar=?, sort_order=?
                 WHERE id=?'
            );
            $stmt->execute([$item_en, $item_ar, $country_en, $country_ar, $sort, $id]);
        } else {
            $stmt = db()->prepare(
                'INSERT INTO item_origins (item_en, item_ar, country_en, country_ar, sort_order)
                 VALUES (?, ?, ?, ?, ?)'
            );
            $stmt->execute([$item_en, $item_ar, $country_en, $country_ar, $sort]);
            $id = db()->lastInsertId();
        }
        json_out(['id' => $id]);
    }

    if ($action === 'delete_origin') {
        $id = intval($body['id'] ?? 0);
        if (!$id) json_out(['error' => 'Missing id'], 422);
        db()->prepare('DELETE FROM item_origins WHERE id=?')->execute([$id]);
        json_out(['ok' => true]);
    }

    // ── USER MANAGEMENT (super admin only) ───────────────────────
    // CRUD for admin_users. Gated by the permission map above
    // ('users' → super admin). Passwords are hashed with PHP's
    // password_hash; the hash itself is never returned to the client.
    if ($action === 'list_users') {
        // Never expose password_hash. Return only display + permission
        // fields needed to render the user list.
        json_out(db()->query(
            'SELECT id, username, display_name,
                    perm_menu, perm_orders, perm_settings,
                    is_super, is_active, branch_id, created_at
             FROM admin_users ORDER BY id'
        )->fetchAll());
    }

    if ($action === 'save_user') {
        $id          = intval($body['id'] ?? 0);
        $username    = trim($body['username'] ?? '');
        $displayName = trim($body['display_name'] ?? '');
        $password    = (string)($body['password'] ?? '');
        $permMenu    = !empty($body['perm_menu'])     ? 1 : 0;
        $permOrders  = !empty($body['perm_orders'])   ? 1 : 0;
        $permSettings= !empty($body['perm_settings']) ? 1 : 0;
        $isSuper     = !empty($body['is_super'])      ? 1 : 0;
        $isActive    = !empty($body['is_active'])     ? 1 : 0;
        // Branch assignment: a specific branch id, or NULL for
        // all-branches access. Super admins are always all-branches,
        // so we force NULL when is_super is set.
        $branchId    = isset($body['branch_id']) && $body['branch_id'] !== ''
                       ? intval($body['branch_id']) : null;
        if ($isSuper) { $branchId = null; }

        if ($username === '') json_out(['error' => 'Username required'], 422);

        // A super admin implicitly has every section — store the
        // perm flags as 1 too so the data is self-consistent if
        // is_super is ever cleared later.
        if ($isSuper) { $permMenu = $permOrders = $permSettings = 1; }

        if ($id) {
            // ── EDIT existing user ──────────────────────────────
            // Guard: don't let the last super admin be demoted or
            // disabled — that would lock everyone out of user mgmt.
            if (!$isSuper || !$isActive) {
                $superCount = (int)db()->query(
                    'SELECT COUNT(*) FROM admin_users WHERE is_super=1 AND is_active=1'
                )->fetchColumn();
                $wasSuper = (int)db()->query(
                    'SELECT is_super FROM admin_users WHERE id=' . $id
                )->fetchColumn();
                if ($wasSuper && $superCount <= 1) {
                    json_out(['error' => 'Cannot demote or disable the last super admin'], 409);
                }
            }

            // Uniqueness check — another row with the same username.
            $dupe = db()->prepare('SELECT id FROM admin_users WHERE username=? AND id<>?');
            $dupe->execute([$username, $id]);
            if ($dupe->fetch()) json_out(['error' => 'Username already taken'], 409);

            if ($password !== '') {
                // Password provided → update it too.
                $hash = password_hash($password, PASSWORD_DEFAULT);
                db()->prepare(
                    'UPDATE admin_users
                     SET username=?, display_name=?, password_hash=?,
                         perm_menu=?, perm_orders=?, perm_settings=?,
                         is_super=?, is_active=?, branch_id=?
                     WHERE id=?'
                )->execute([
                    $username, $displayName, $hash,
                    $permMenu, $permOrders, $permSettings,
                    $isSuper, $isActive, $branchId, $id
                ]);
            } else {
                // No password → leave the existing hash untouched.
                db()->prepare(
                    'UPDATE admin_users
                     SET username=?, display_name=?,
                         perm_menu=?, perm_orders=?, perm_settings=?,
                         is_super=?, is_active=?, branch_id=?
                     WHERE id=?'
                )->execute([
                    $username, $displayName,
                    $permMenu, $permOrders, $permSettings,
                    $isSuper, $isActive, $branchId, $id
                ]);
            }
            json_out(['id' => $id]);
        } else {
            // ── CREATE new user ─────────────────────────────────
            if ($password === '') json_out(['error' => 'Password required for new user'], 422);

            $dupe = db()->prepare('SELECT id FROM admin_users WHERE username=?');
            $dupe->execute([$username]);
            if ($dupe->fetch()) json_out(['error' => 'Username already taken'], 409);

            $hash = password_hash($password, PASSWORD_DEFAULT);
            db()->prepare(
                'INSERT INTO admin_users
                   (username, display_name, password_hash,
                    perm_menu, perm_orders, perm_settings, is_super, is_active, branch_id)
                 VALUES (?,?,?,?,?,?,?,?,?)'
            )->execute([
                $username, $displayName, $hash,
                $permMenu, $permOrders, $permSettings, $isSuper, $isActive, $branchId
            ]);
            json_out(['id' => db()->lastInsertId()]);
        }
    }

    if ($action === 'delete_user') {
        $id = intval($body['id'] ?? 0);
        if (!$id) json_out(['error' => 'Missing id'], 422);

        // Guard 1: can't delete yourself — you'd lose your own session
        // mid-action and it's almost always a mistake.
        if ($id === (int)($_SESSION['admin_id'] ?? 0)) {
            json_out(['error' => 'You cannot delete your own account'], 409);
        }
        // Guard 2: can't delete the last active super admin.
        $wasSuper = (int)db()->query(
            'SELECT is_super FROM admin_users WHERE id=' . $id
        )->fetchColumn();
        if ($wasSuper) {
            $superCount = (int)db()->query(
                'SELECT COUNT(*) FROM admin_users WHERE is_super=1 AND is_active=1'
            )->fetchColumn();
            if ($superCount <= 1) {
                json_out(['error' => 'Cannot delete the last super admin'], 409);
            }
        }
        db()->prepare('DELETE FROM admin_users WHERE id=?')->execute([$id]);
        json_out(['ok' => true]);
    }

    // ── BRANCHES (super admin only) ──────────────────────────────
    // CRUD for the branches table. Gated by the permission map
    // ('users' → super admin; branches are a super-admin concern).
    if ($action === 'list_branches') {
        json_out(db()->query(
            'SELECT id, name, name_ar, slug, address, address_ar,
                    phone, is_active, sort_order
             FROM branches ORDER BY sort_order, id'
        )->fetchAll());
    }

    if ($action === 'save_branch') {
        $id        = intval($body['id'] ?? 0);
        $name      = trim($body['name'] ?? '');
        $nameAr    = trim($body['name_ar'] ?? '');
        $address   = trim($body['address'] ?? '');
        $addressAr = trim($body['address_ar'] ?? '');
        $phone     = trim($body['phone'] ?? '');
        $isActive  = !empty($body['is_active']) ? 1 : 0;
        $sort      = intval($body['sort_order'] ?? 0);

        if ($name === '') json_out(['error' => 'Branch name required'], 422);

        // Slug: use the supplied one, else derive from the English
        // name. Same transform as categories — lowercase, non-
        // alphanumerics to hyphens.
        $slug = trim($body['slug'] ?? '');
        if ($slug === '') {
            $slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $name));
            $slug = trim($slug, '-');
        }
        if ($slug === '') json_out(['error' => 'Could not derive a slug from the name'], 422);

        // Slug must be unique across branches.
        $dupe = db()->prepare('SELECT id FROM branches WHERE slug=? AND id<>?');
        $dupe->execute([$slug, $id]);
        if ($dupe->fetch()) {
            json_out(['error' => 'Another branch already uses the slug "' . $slug . '"'], 409);
        }

        if ($id) {
            db()->prepare(
                'UPDATE branches
                 SET name=?, name_ar=?, slug=?, address=?, address_ar=?,
                     phone=?, is_active=?, sort_order=?
                 WHERE id=?'
            )->execute([$name, $nameAr, $slug, $address, $addressAr,
                        $phone, $isActive, $sort, $id]);
        } else {
            db()->prepare(
                'INSERT INTO branches
                   (name, name_ar, slug, address, address_ar, phone, is_active, sort_order)
                 VALUES (?,?,?,?,?,?,?,?)'
            )->execute([$name, $nameAr, $slug, $address, $addressAr,
                        $phone, $isActive, $sort]);
            $id = db()->lastInsertId();
        }
        json_out(['id' => $id]);
    }

    if ($action === 'delete_branch') {
        $id = intval($body['id'] ?? 0);
        if (!$id) json_out(['error' => 'Missing id'], 422);

        // Guard: don't delete a branch that still has orders — those
        // orders would be orphaned. Admin should reassign or keep
        // the branch (they can hide it with is_active instead).
        $oc = (int)db()->query('SELECT COUNT(*) FROM orders WHERE branch_id=' . $id)->fetchColumn();
        if ($oc > 0) {
            json_out(['error' => 'This branch has ' . $oc . ' order(s). Hide it instead of deleting.'], 409);
        }
        // Guard: don't delete a branch that users are assigned to.
        $uc = (int)db()->query('SELECT COUNT(*) FROM admin_users WHERE branch_id=' . $id)->fetchColumn();
        if ($uc > 0) {
            json_out(['error' => 'Users are assigned to this branch. Reassign them first.'], 409);
        }
        db()->prepare('DELETE FROM branches WHERE id=?')->execute([$id]);
        json_out(['ok' => true]);
    }

    if ($action === 'list_categories') {
        // Return every column, including name_ar, so the admin panel
        // can pre-fill the Arabic input when the user opens the edit modal.
        json_out(db()->query('SELECT * FROM categories ORDER BY sort_order')->fetchAll());
    }

    if ($action === 'save_category') {
        $name    = trim($body['name'] ?? '');
        $nameAr  = nullable_trim($body['name_ar'] ?? '');
        $headerImg = nullable_trim($body['header_image_url'] ?? '');
        $headerVideo = nullable_trim($body['header_video_url'] ?? '');
        $slug    = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $name));
        if (!$name) json_out(['error' => 'Name required'], 422);

        $id = intval($body['id'] ?? 0);
        if ($id) {
            $stmt = db()->prepare(
                'UPDATE categories
                 SET name=?, name_ar=?, header_image_url=?, header_video_url=?, slug=?, sort_order=?, is_active=?
                 WHERE id=?'
            );
            $stmt->execute([
                $name, $nameAr, $headerImg, $headerVideo, $slug,
                intval($body['sort_order'] ?? 0),
                intval($body['is_active']  ?? 1),
                $id
            ]);
        } else {
            $stmt = db()->prepare(
                'INSERT INTO categories (name, name_ar, header_image_url, header_video_url, slug, sort_order, is_active)
                 VALUES (?, ?, ?, ?, ?, ?, ?)'
            );
            $stmt->execute([
                $name, $nameAr, $headerImg, $headerVideo, $slug,
                intval($body['sort_order'] ?? 0),
                intval($body['is_active']  ?? 1)
            ]);
            $id = db()->lastInsertId();
        }
        json_out(['id' => $id]);
    }

    if ($action === 'delete_category') {
        // Categories cascade-delete their items via the foreign key,
        // so this single statement also removes all child items, sizes, extras.
        $stmt = db()->prepare('DELETE FROM categories WHERE id=?');
        $stmt->execute([intval($body['id'])]);
        json_out(['ok' => true]);
    }

    // ── ITEMS ─────────────────────────────────────────────────
    if ($action === 'list_items') {
        // Join to get category_name for the UI. We return every column
        // from items, which now includes name_ar and description_ar.
        $catId  = intval($_GET['cat'] ?? 0);
        $sql    = 'SELECT i.*, c.name AS category_name, c.name_ar AS category_name_ar
                   FROM items i JOIN categories c ON c.id=i.category_id';
        $params = [];
        if ($catId) { $sql .= ' WHERE i.category_id=?'; $params[] = $catId; }
        $sql .= ' ORDER BY i.category_id, i.sort_order';

        $stmt = db()->prepare($sql);
        $stmt->execute($params);
        $items = $stmt->fetchAll();

        // Attach sizes, extras, and gallery photos to each item
        // (each include their _ar labels where applicable)
        foreach ($items as &$item) {
            $s = db()->prepare('SELECT * FROM sizes  WHERE item_id=? ORDER BY sort_order');
            $s->execute([$item['id']]);
            $item['sizes']  = $s->fetchAll();

            $e = db()->prepare('SELECT * FROM extras WHERE item_id=? ORDER BY sort_order');
            $e->execute([$item['id']]);
            $item['extras'] = $e->fetchAll();

            // Gallery photos — ordered by sort_order so the admin's
            // reordering survives a page reload. Each row carries
            // is_video so the renderer can tell images from videos.
            // Wrapped in try/catch so a missing item_photos table
            // or missing is_video column doesn't break the items
            // list. The admin just sees no gallery rows until the
            // schema migration is run.
            try {
                $p = db()->prepare('SELECT id, url, is_video FROM item_photos WHERE item_id=? ORDER BY sort_order');
                $p->execute([$item['id']]);
                $item['photos'] = $p->fetchAll();
            } catch (PDOException $e) {
                try {
                    $p = db()->prepare('SELECT id, url FROM item_photos WHERE item_id=? ORDER BY sort_order');
                    $p->execute([$item['id']]);
                    $rows = $p->fetchAll();
                    foreach ($rows as &$row) { $row['is_video'] = 0; }
                    $item['photos'] = $rows;
                } catch (PDOException $e2) {
                    $item['photos'] = [];
                }
            }

            // Extra categories — the "also appears in" links from the
            // item_categories join table. Returned as a plain array
            // of category IDs the admin item editor uses to pre-tick
            // its checkboxes. try/catch so a pre-migration DB (no
            // item_categories table) still serves the items list.
            try {
                $ic = db()->prepare('SELECT category_id FROM item_categories WHERE item_id=?');
                $ic->execute([$item['id']]);
                $item['extra_category_ids'] = array_map('intval', $ic->fetchAll(PDO::FETCH_COLUMN));
            } catch (PDOException $e) {
                $item['extra_category_ids'] = [];
            }

            // Branch assignment — which branches carry this item.
            // The item editor uses this to pre-tick its branch
            // checkboxes. try/catch for pre-migration DBs.
            try {
                $ib = db()->prepare('SELECT branch_id FROM item_branches WHERE item_id=?');
                $ib->execute([$item['id']]);
                $item['branch_ids'] = array_map('intval', $ib->fetchAll(PDO::FETCH_COLUMN));
            } catch (PDOException $e) {
                $item['branch_ids'] = [];
            }
        }
        json_out($items);
    }

    if ($action === 'save_item') {
        $id = intval($body['id'] ?? 0);

        // Photo crop position — controls customer-side object-position
        // when the photo gets cropped to fit the tile/modal frame.
        // Constrained to one of the three valid values; anything
        // unrecognized falls back to 'bottom' (the global default).
        $photoPos = $body['photo_position'] ?? 'bottom';
        if (!in_array($photoPos, ['top', 'center', 'bottom'], true)) {
            $photoPos = 'bottom';
        }

        // Build the ordered parameter list matching the SQL column order.
        // Putting it in a variable keeps the INSERT and UPDATE queries in sync.
        $fields = [
            intval($body['category_id'] ?? 0),
            trim($body['name'] ?? ''),
            nullable_trim($body['name_ar'] ?? ''),
            trim($body['description'] ?? ''),
            nullable_trim($body['description_ar'] ?? ''),
            floatval($body['base_price'] ?? 0),
            trim($body['photo_url'] ?? ''),
            $photoPos,
            trim($body['badge'] ?? ''),
            intval($body['is_active'] ?? 1),
            intval($body['show_in_ordering'] ?? 1),
            intval($body['show_in_showcase'] ?? 1),
            intval($body['sort_order'] ?? 0),
            // Per-mode availability (Brand B). Default 1 = available
            // for that mode. La Tarte (single_location) ignores these.
            !empty($body['mode_delivery']) ? 1 : 0,
            !empty($body['mode_pickup'])   ? 1 : 0,
            !empty($body['mode_dinein'])   ? 1 : 0,
        ];
        if (!$fields[0] || !$fields[1]) json_out(['error' => 'Category and name required'], 422);

        if ($id) {
            $stmt = db()->prepare(
                'UPDATE items
                 SET category_id=?, name=?, name_ar=?, description=?, description_ar=?,
                     base_price=?, photo_url=?, photo_position=?, badge=?, is_active=?,
                     show_in_ordering=?, show_in_showcase=?, sort_order=?,
                     mode_delivery=?, mode_pickup=?, mode_dinein=?
                 WHERE id=?'
            );
            $stmt->execute([...$fields, $id]);
        } else {
            $stmt = db()->prepare(
                'INSERT INTO items
                   (category_id, name, name_ar, description, description_ar,
                    base_price, photo_url, photo_position, badge, is_active,
                    show_in_ordering, show_in_showcase, sort_order,
                    mode_delivery, mode_pickup, mode_dinein)
                 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
            );
            $stmt->execute($fields);
            $id = db()->lastInsertId();
        }

        // Sizes: we delete-and-reinsert the full list on every save, which is
        // simple and correct. Each row now carries label_ar alongside label.
        db()->prepare('DELETE FROM sizes WHERE item_id=?')->execute([$id]);
        foreach (($body['sizes'] ?? []) as $i => $s) {
            if (!trim($s['label'] ?? '')) continue;
            $stmt = db()->prepare(
                'INSERT INTO sizes (item_id, label, label_ar, price_add, sort_order)
                 VALUES (?, ?, ?, ?, ?)'
            );
            $stmt->execute([
                $id,
                trim($s['label']),
                nullable_trim($s['label_ar'] ?? ''),
                floatval($s['price_add'] ?? 0),
                $i
            ]);
        }

        // Extras: same delete-and-reinsert approach, with name_ar.
        db()->prepare('DELETE FROM extras WHERE item_id=?')->execute([$id]);
        foreach (($body['extras'] ?? []) as $i => $e) {
            if (!trim($e['name'] ?? '')) continue;
            $stmt = db()->prepare(
                'INSERT INTO extras (item_id, name, name_ar, price, sort_order)
                 VALUES (?, ?, ?, ?, ?)'
            );
            $stmt->execute([
                $id,
                trim($e['name']),
                nullable_trim($e['name_ar'] ?? ''),
                floatval($e['price'] ?? 0),
                $i
            ]);
        }

        // Gallery photos: same delete-and-reinsert pattern as sizes/extras.
        // We accept an array of { url, is_video } objects; ordering is
        // determined by array index, so the admin UI can reorder simply
        // by rearranging the array before sending it. Rows with empty
        // URLs are skipped.
        // Wrapped in try/catch so a missing item_photos table doesn't
        // block saving the item itself — admin can still edit name,
        // price, etc. without the gallery feature being available.
        try {
            db()->prepare('DELETE FROM item_photos WHERE item_id=?')->execute([$id]);
            foreach (($body['photos'] ?? []) as $i => $p) {
                $url = trim($p['url'] ?? '');
                if (!$url) continue;
                $isVideo = !empty($p['is_video']) ? 1 : 0;
                try {
                    $stmt = db()->prepare(
                        'INSERT INTO item_photos (item_id, url, is_video, sort_order) VALUES (?, ?, ?, ?)'
                    );
                    $stmt->execute([$id, $url, $isVideo, $i]);
                } catch (PDOException $e) {
                    // is_video column might not exist — try without it.
                    $stmt = db()->prepare(
                        'INSERT INTO item_photos (item_id, url, sort_order) VALUES (?, ?, ?)'
                    );
                    $stmt->execute([$id, $url, $i]);
                }
            }
        } catch (PDOException $e) {
            // Table doesn't exist — silently skip gallery save.
        }

        // Extra categories — the "also appears in" links. Same
        // delete-and-reinsert pattern: clear this item's join rows,
        // then insert one per selected extra category. We skip any
        // extra that equals the primary category_id (the item is
        // already in its primary; a join row would be redundant).
        // Wrapped in try/catch so a pre-migration DB without the
        // item_categories table doesn't block saving the item.
        try {
            db()->prepare('DELETE FROM item_categories WHERE item_id=?')->execute([$id]);
            $primaryCat = intval($body['category_id'] ?? 0);
            $seen = [];
            foreach (($body['extra_category_ids'] ?? []) as $catId) {
                $catId = intval($catId);
                // Skip invalid, the primary itself, and duplicates.
                if ($catId <= 0 || $catId === $primaryCat || isset($seen[$catId])) {
                    continue;
                }
                $seen[$catId] = true;
                db()->prepare(
                    'INSERT INTO item_categories (item_id, category_id) VALUES (?, ?)'
                )->execute([$id, $catId]);
            }
        } catch (PDOException $e) {
            // item_categories table doesn't exist — skip; the item
            // still saves with just its primary category.
        }

        // Branch assignment (Brand B). Same delete-and-reinsert
        // pattern: clear this item's branch links, reinsert one per
        // selected branch. If no branch_ids were sent at all (e.g.
        // the field is absent because site_mode is single_location),
        // we leave existing links untouched — single-location installs
        // never manage branches and shouldn't have their data wiped.
        if (array_key_exists('branch_ids', $body)) {
            try {
                db()->prepare('DELETE FROM item_branches WHERE item_id=?')->execute([$id]);
                $seenB = [];
                foreach (($body['branch_ids'] ?? []) as $bId) {
                    $bId = intval($bId);
                    if ($bId <= 0 || isset($seenB[$bId])) continue;
                    $seenB[$bId] = true;
                    db()->prepare(
                        'INSERT INTO item_branches (item_id, branch_id) VALUES (?, ?)'
                    )->execute([$id, $bId]);
                }
            } catch (PDOException $e) {
                // item_branches table missing — skip silently.
            }
        }

        json_out(['id' => $id]);
    }

    if ($action === 'delete_item') {
        // Deleting a master product removes it from EVERY branch
        // (item_branches cascades). That's a head-office action —
        // a branch-scoped user must not be able to wipe a product
        // for all 10 branches. Only all-branches admins (branch_id
        // NULL, which includes super admins) may delete.
        if (current_branch_id() !== null) {
            json_out(['error' => 'Branch managers cannot delete products. '
                . 'Untick your branch on the item instead, or ask head office.'], 403);
        }
        $stmt = db()->prepare('DELETE FROM items WHERE id=?');
        $stmt->execute([intval($body['id'])]);
        json_out(['ok' => true]);
    }

    // ── ITEM VISIBILITY TOGGLES ─────────────────────────────
    // Used by the inline pill badges in the items list. Quick
    // way to flip show_in_ordering or show_in_showcase without
    // opening the full edit modal.
    //
    // Body: { id: int, field: 'show_in_ordering'|'show_in_showcase', value: 0|1 }
    if ($action === 'set_item_visibility') {
        $id    = intval($body['id'] ?? 0);
        $field = $body['field'] ?? '';
        $value = intval($body['value'] ?? 0) ? 1 : 0;
        if (!$id) json_out(['error' => 'Missing id'], 422);
        if (!in_array($field, ['show_in_ordering', 'show_in_showcase'], true)) {
            json_out(['error' => 'Invalid field'], 422);
        }
        // Field name is whitelisted above so direct interpolation is safe.
        db()->prepare("UPDATE items SET $field = ? WHERE id = ?")
            ->execute([$value, $id]);
        json_out(['ok' => true]);
    }

    // Bulk toggle for an entire category. Used by the "Apply to all
    // in this category" power-user buttons in the admin items list.
    //
    // Body: {
    //   category_id: int (0 = all categories),
    //   field:       'show_in_ordering' | 'show_in_showcase',
    //   value:       0 | 1
    // }
    //
    // Returns the number of rows updated so the UI can show "12 items
    // updated" feedback.
    if ($action === 'bulk_set_visibility') {
        $catId = intval($body['category_id'] ?? 0);
        $field = $body['field'] ?? '';
        $value = intval($body['value'] ?? 0) ? 1 : 0;
        if (!in_array($field, ['show_in_ordering', 'show_in_showcase'], true)) {
            json_out(['error' => 'Invalid field'], 422);
        }
        if ($catId > 0) {
            $stmt = db()->prepare("UPDATE items SET $field = ? WHERE category_id = ?");
            $stmt->execute([$value, $catId]);
        } else {
            $stmt = db()->prepare("UPDATE items SET $field = ?");
            $stmt->execute([$value]);
        }
        json_out(['ok' => true, 'updated' => $stmt->rowCount()]);
    }

    // ── MEDIA UPLOAD ──────────────────────────────────────────
    // Handles both images (item photos, gallery images, popup
    // image) and videos (popup video). A `kind` query string
    // parameter tells us which limits and extensions to apply.
    //   kind=photo  → images only, standard size limit
    //   kind=video  → videos only, 5× the image size limit
    //   kind=media  → either images or videos (used by popup)
    // The endpoint accepts the file under $_FILES['photo'] for
    // backwards compatibility with existing upload UI; newer
    // clients can also send it as $_FILES['file'].
    // ── BROWSE EXISTING UPLOADS ──────────────────────────────────
    // Returns every file in /uploads/ with metadata, plus an
    // in_use flag that tells the admin UI whether the file is
    // referenced by any item or category. Frontend uses this to
    // power a "browse / pick existing" picker as an alternative
    // to re-uploading the same file.
    //
    // Optional query params:
    //   ?type=image  → only image files
    //   ?type=video  → only video files
    //   (no type)    → all files
    if ($action === 'list_uploads') {
        $uploadDir = __DIR__ . '/../uploads/';
        if (!is_dir($uploadDir)) json_out([]);

        $type = $_GET['type'] ?? '';
        $imgExts   = ['jpg','jpeg','png','webp','gif'];
        $videoExts = ['mp4','webm','mov'];
        $allowedExts = ($type === 'image') ? $imgExts
                     : (($type === 'video') ? $videoExts
                                            : array_merge($imgExts, $videoExts));

        // Build the set of "in use" URLs by querying every column
        // that can reference an upload. Done as a single pass so
        // we don't re-query for each file. Each URL is normalized
        // to just its filename for comparison (the DB stores full
        // URLs which include scheme + host, but those can change
        // when domains migrate — so we compare on filename only).
        $inUseFilenames = [];
        $sources = [
            'SELECT photo_url AS u FROM items WHERE photo_url IS NOT NULL AND photo_url != \'\'',
            'SELECT url AS u FROM item_photos WHERE url IS NOT NULL AND url != \'\'',
            'SELECT header_image_url AS u FROM categories WHERE header_image_url IS NOT NULL AND header_image_url != \'\'',
            'SELECT header_video_url AS u FROM categories WHERE header_video_url IS NOT NULL AND header_video_url != \'\'',
        ];
        foreach ($sources as $sql) {
            try {
                foreach (db()->query($sql)->fetchAll(PDO::FETCH_COLUMN) as $url) {
                    // Extract the filename from any URL. Works for
                    // full URLs, relative paths, or bare filenames.
                    $fn = basename(parse_url($url, PHP_URL_PATH) ?: $url);
                    if ($fn) $inUseFilenames[$fn] = true;
                }
            } catch (PDOException $e) {
                // table might not exist (item_photos on older DBs); skip
            }
        }

        $files = [];
        foreach (scandir($uploadDir) as $fname) {
            if ($fname[0] === '.') continue;          // skip hidden files
            $full = $uploadDir . $fname;
            if (!is_file($full)) continue;
            $ext = strtolower(pathinfo($fname, PATHINFO_EXTENSION));
            if (!in_array($ext, $allowedExts)) continue;

            $files[] = [
                'filename' => $fname,
                'url'      => UPLOAD_URL . $fname,
                'size'     => filesize($full),
                'mtime'    => filemtime($full),
                'is_image' => in_array($ext, $imgExts),
                'is_video' => in_array($ext, $videoExts),
                'in_use'   => isset($inUseFilenames[$fname]),
            ];
        }
        // Newest first — typically what the admin wants when
        // they're looking for "the file I just uploaded."
        usort($files, function($a, $b) { return $b['mtime'] - $a['mtime']; });
        json_out($files);
    }

    // ── BATCH SORT ORDER UPDATE ──────────────────────────────────
    // Accepts a list of {id, sort_order} pairs and updates them
    // all in one transaction. Used by the drag-and-drop reorder
    // UI in admin — after a drop, the frontend computes new
    // sort_orders for every affected row and posts them here.
    //
    // The "kind" param controls which table is updated:
    //   category → categories.sort_order
    //   item     → items.sort_order
    //   photo    → item_photos.sort_order
    //
    // Any row with id not in the request is left alone.
    if ($action === 'save_sort_orders') {
        $kind   = $body['kind']   ?? '';
        $orders = $body['orders'] ?? [];
        $tableMap = [
            'category' => 'categories',
            'item'     => 'items',
            'photo'    => 'item_photos',
        ];
        if (!isset($tableMap[$kind])) {
            json_out(['error' => 'Invalid kind (must be category|item|photo)'], 422);
        }
        if (!is_array($orders) || count($orders) === 0) {
            json_out(['ok' => true, 'updated' => 0]);
        }
        $table = $tableMap[$kind];
        db()->beginTransaction();
        try {
            $stmt = db()->prepare("UPDATE $table SET sort_order = ? WHERE id = ?");
            $count = 0;
            foreach ($orders as $o) {
                $id = intval($o['id'] ?? 0);
                $so = intval($o['sort_order'] ?? 0);
                if ($id <= 0) continue;
                $stmt->execute([$so, $id]);
                $count++;
            }
            db()->commit();
            json_out(['ok' => true, 'updated' => $count]);
        } catch (Exception $e) {
            db()->rollBack();
            json_out(['error' => 'Update failed: ' . $e->getMessage()], 500);
        }
    }

    if ($action === 'upload_photo') {
        $uploadDir = __DIR__ . '/../uploads/';
        if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);

        $file = $_FILES['photo'] ?? $_FILES['file'] ?? null;
        if (!$file || $file['error']) json_out(['error' => 'Upload failed'], 422);

        $kind = $_REQUEST['kind'] ?? 'photo';
        $imgExts   = ['jpg','jpeg','png','webp','gif'];
        $videoExts = ['mp4','webm','mov'];
        $allowed   = ($kind === 'video') ? $videoExts
                   : (($kind === 'media') ? array_merge($imgExts, $videoExts) : $imgExts);

        $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        if (!in_array($ext, $allowed)) {
            json_out(['error' => 'File type not allowed (got .' . $ext . ')'], 422);
        }

        // Videos get a bigger budget since even modest-quality portrait
        // clips easily exceed a few MB. Images stay on the original limit.
        $isVideo  = in_array($ext, $videoExts);
        $maxBytes = ($isVideo ? MAX_UPLOAD_MB * 5 : MAX_UPLOAD_MB) * 1024 * 1024;
        if ($file['size'] > $maxBytes) {
            json_out(['error' => 'File too large (max ' . round($maxBytes / 1024 / 1024) . ' MB)'], 422);
        }

        $fname = uniqid($isVideo ? 'vid_' : 'img_', true) . '.' . $ext;
        move_uploaded_file($file['tmp_name'], $uploadDir . $fname);
        json_out(['url' => UPLOAD_URL . $fname, 'kind' => $isVideo ? 'video' : 'image']);
    }

    // ── ORDERS (admin only) ───────────────────────────────────
    // Shape of responses is optimized for the dashboard UI.
    // list_orders returns the last N orders with counts by status
    // so the dashboard can show "3 new, 2 preparing" tab headers.

    if ($action === 'list_orders') {
        // By default we return the last 50 orders (any status).
        // The dashboard calls this every few seconds on a polling
        // cadence, so we keep the response small and focused.
        $limit = min(200, max(10, intval($_GET['limit'] ?? 50)));
        $statusFilter = $_GET['status'] ?? '';
        $sql = 'SELECT o.id, o.order_number, o.customer_name, o.customer_phone,
                       o.customer_notes, o.language, o.total, o.currency,
                       o.status, o.payment_method, o.payment_status,
                       o.mf_invoice_id, o.mf_payment_id,
                       o.branch_id, o.order_mode, o.order_table_number,
                       o.items_json, o.created_at, o.updated_at,
                       b.name AS branch_name
                FROM orders o
                LEFT JOIN branches b ON b.id = o.branch_id';
        $where  = [];
        $params = [];
        $allowedStatuses = ['new', 'preparing', 'ready', 'completed', 'voided',
                            'pending_whatsapp', 'pending_payment', 'payment_failed', 'abandoned'];
        if ($statusFilter !== '' && in_array($statusFilter, $allowedStatuses, true)) {
            $where[] = 'o.status = ?';
            $params[] = $statusFilter;
        }
        // Branch scoping: a branch-assigned user sees ONLY their
        // branch's orders. All-branches users (branch_id NULL,
        // incl. super admins) see everything. This is the delivery-
        // routing payoff — an order placed for an area tied to
        // Jabriya shows up only on the Jabriya manager's screen.
        $scopeBranch = current_branch_id();
        if ($scopeBranch !== null) {
            $where[] = 'o.branch_id = ?';
            $params[] = $scopeBranch;
        }
        if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
        $sql .= ' ORDER BY o.id DESC LIMIT ' . $limit;
        try {
            $stmt = db()->prepare($sql);
            $stmt->execute($params);
            $orders = $stmt->fetchAll();
        } catch (PDOException $e) {
            // No branches table (single_location install) — fall back
            // to a plain orders query with no join and no branch scope.
            $fallbackSql = 'SELECT id, order_number, customer_name, customer_phone,
                                   customer_notes, language, total, currency,
                                   status, payment_method, payment_status,
                                   mf_invoice_id, mf_payment_id,
                                   items_json, created_at, updated_at
                            FROM orders';
            $fbParams = [];
            if ($statusFilter !== '' && in_array($statusFilter, $allowedStatuses, true)) {
                $fallbackSql .= ' WHERE status = ?';
                $fbParams[] = $statusFilter;
            }
            $fallbackSql .= ' ORDER BY id DESC LIMIT ' . $limit;
            $stmt = db()->prepare($fallbackSql);
            $stmt->execute($fbParams);
            $orders = $stmt->fetchAll();
        }

        // Status counts — also branch-scoped so a branch manager's
        // tab counters match what they actually see.
        $countSql = "SELECT status, COUNT(*) AS n FROM orders
                     WHERE created_at > NOW() - INTERVAL 24 HOUR";
        $countParams = [];
        if ($scopeBranch !== null) {
            $countSql .= ' AND branch_id = ?';
            $countParams[] = $scopeBranch;
        }
        $countSql .= ' GROUP BY status';
        $cstmt = db()->prepare($countSql);
        $cstmt->execute($countParams);
        $counts = $cstmt->fetchAll(PDO::FETCH_KEY_PAIR);

        json_out(['orders' => $orders, 'counts' => $counts]);
    }

    if ($action === 'get_order') {
        // Full details for a single order, including line items.
        // Used by the detail/print view in the dashboard.
        $id = intval($_GET['id'] ?? 0);
        if (!$id) json_out(['error' => 'id required'], 422);
        $stmt = db()->prepare('SELECT * FROM orders WHERE id = ?');
        $stmt->execute([$id]);
        $order = $stmt->fetch();
        if (!$order) json_out(['error' => 'Not found'], 404);
        $lines = db()->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY id');
        $lines->execute([$id]);
        $order['lines'] = $lines->fetchAll();
        json_out($order);
    }

    if ($action === 'update_order_status') {
        // status is validated against a fixed set to prevent
        // the dashboard from accidentally writing free-text
        // values into the status column.
        $id = intval($body['id'] ?? 0);
        $newStatus = $body['status'] ?? '';
        $valid = ['pending_whatsapp','new','preparing','ready','completed','voided','abandoned'];
        if (!$id || !in_array($newStatus, $valid, true)) {
            json_out(['error' => 'Invalid id or status'], 422);
        }

        // Look up the current status first. If we're transitioning
        // pending_whatsapp → new, that's "confirmation" and we
        // fire the email notification. This is the single trigger
        // for confirmed-order emails across the app.
        $cur = db()->prepare('SELECT status FROM orders WHERE id = ?');
        $cur->execute([$id]);
        $prevStatus = $cur->fetchColumn();

        $stmt = db()->prepare('UPDATE orders SET status = ? WHERE id = ?');
        $stmt->execute([$newStatus, $id]);

        $emailResult = null;
        if ($prevStatus === 'pending_whatsapp' && $newStatus === 'new') {
            require_once __DIR__ . '/../mailer.php';
            // Load full order + items for the email body
            $o = db()->prepare('SELECT * FROM orders WHERE id = ?');
            $o->execute([$id]);
            $o = $o->fetch();
            if ($o) {
                $items = json_decode($o['items_json'] ?? '[]', true) ?: [];
                try {
                    $emailResult = send_order_email(
                        $o['order_number'], $o['customer_name'], $o['customer_phone'],
                        $o['customer_notes'] ?? '', $o['language'] ?? 'en',
                        $items, floatval($o['total']), $o['currency'] ?? 'KD'
                    );
                } catch (Throwable $e) {
                    $emailResult = ['sent' => false, 'error' => $e->getMessage()];
                }
            }
        }

        json_out(['ok' => true, 'email' => $emailResult]);
    }

    // ── TEST EMAIL ──────────────────────────────────────────────
    // Admin Settings has a "Send test email" button that hits this
    // endpoint. We send a tiny test message to the configured
    // recipient and return the actual send result so admin can
    // see whether mail() succeeded. Invaluable for diagnosing
    // "my emails aren't arriving" without placing fake orders.
    if ($action === 'test_email') {
        require_once __DIR__ . '/../mailer.php';
        $s = db()->query("SELECT key_name, value FROM settings WHERE key_name IN
            ('order_email_to', 'order_email_from', 'restaurant_name')")
            ->fetchAll(PDO::FETCH_KEY_PAIR);
        $to = trim($s['order_email_to'] ?? '');
        if ($to === '') {
            json_out(['ok' => false, 'error' => 'No recipient configured. Fill in the "Recipient email" field first.'], 422);
        }
        $restName = $s['restaurant_name'] ?? 'Grove Menu';
        $html = '<!doctype html><html><body style="font-family:Arial,sans-serif;padding:24px">
            <h2 style="color:#c4362c">✓ Test email received</h2>
            <p>If you\'re reading this, your order notification setup is working.</p>
            <p style="color:#888;font-size:12px">Sent at ' . date('Y-m-d H:i:s') .
            ' from ' . htmlspecialchars(SITE_URL, ENT_QUOTES) . '</p>
            </body></html>';
        $result = mail_html($to, "[$restName] Test email", $html,
                            $s['order_email_from'] ?? '', $restName);
        json_out($result);
    }

    // ── TEST PAYMENT GATEWAY CONNECTION ─────────────────────────
    // Admin Settings has a "Test connection" button next to the
    // MyFatoorah credentials. Hits this endpoint, which calls
    // mf_verify_connection() and returns the result so the admin
    // page can show a real success/failure indicator.
    if ($action === 'test_payment_connection') {
        require_once __DIR__ . '/../myfatoorah.php';
        $result = mf_verify_connection();
        json_out($result);
    }

    // ── SETTINGS ──────────────────────────────────────────────
    // The settings endpoints are generic key-value — they already
    // work with any new keys we add (restaurant_name_ar, default_language, etc.)
    // without any code change here.
    if ($action === 'get_settings') {
        $rows = db()->query('SELECT key_name, value FROM settings')->fetchAll();
        json_out(array_column($rows, 'value', 'key_name'));
    }

    if ($action === 'save_settings') {
        $stmt = db()->prepare(
            'INSERT INTO settings (key_name, value) VALUES (?, ?)
             ON DUPLICATE KEY UPDATE value=?'
        );
        foreach ($body as $k => $v) {
            $stmt->execute([trim($k), trim($v), trim($v)]);
        }
        json_out(['ok' => true]);
    }

    // ── CHANGE PASSWORD ───────────────────────────────────────
    if ($action === 'change_password') {
        $cur = $body['current'] ?? '';
        $new = $body['new'] ?? '';
        if (strlen($new) < 8) json_out(['error' => 'Password must be 8+ characters'], 422);
        $row = db()->prepare('SELECT password_hash FROM admin_users WHERE id=?');
        $row->execute([$_SESSION['admin_id']]);
        $row = $row->fetch();
        if (!$row || !password_verify($cur, $row['password_hash'])) json_out(['error' => 'Current password incorrect'], 403);
        $hash = password_hash($new, PASSWORD_BCRYPT);
        db()->prepare('UPDATE admin_users SET password_hash=? WHERE id=?')->execute([$hash, $_SESSION['admin_id']]);
        json_out(['ok' => true]);
    }

    json_out(['error' => 'Unknown action'], 400);

} catch (Throwable $e) {
    json_out(['error' => $e->getMessage()], 500);
}
