<?php
header("Content-Type: application/json");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, X-Auth-Token, X-Company, Authorization, X-Requested-With");


// Uncomment the block below only when debugging locally — never in production.
// ini_set('display_errors', 1);
// ini_set('display_startup_errors', 1);
// error_reporting(E_ALL);.


if (!isset($_SERVER['REQUEST_METHOD'])) $_SERVER['REQUEST_METHOD'] = 'CLI';

// Load environment config from .env file (must come before CORS so ALLOWED_ORIGINS is available)
$_env_path = __DIR__ . '/.env';
if (file_exists($_env_path)) {
    $_env_vars = parse_ini_file($_env_path, false, INI_SCANNER_RAW);
    if (is_array($_env_vars)) {
        foreach ($_env_vars as $_k => $_v) {
            if (!isset($_ENV[$_k])) $_ENV[$_k] = $_v;
        }
    }
    unset($_env_vars, $_k, $_v);
}
unset($_env_path);

// Structured logger — must be loaded before any logging calls
require_once __DIR__ . '/logger.php';
Logger::init(__DIR__ . '/logs');

// CORS — restrict to configured origins (set ALLOWED_ORIGINS=* in .env for dev)
$_allowed_origins_raw = $_ENV['ALLOWED_ORIGINS'] ?? '*';
$_request_origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($_allowed_origins_raw === '*') {
    header("Access-Control-Allow-Origin: *");
} else {
    $_allowed_list = array_map('trim', explode(',', $_allowed_origins_raw));
    if (in_array($_request_origin, $_allowed_list, true)) {
        header("Access-Control-Allow-Origin: " . $_request_origin);
        header("Vary: Origin");
    }
}
unset($_allowed_origins_raw, $_allowed_list);

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    exit;
}

// ── ping — must be handled before DB connections so it never fails ───────────
$_early_action = $_GET['action'] ?? (json_decode(file_get_contents('php://input'), true)['action'] ?? '');
if ($_early_action === 'ping') {
    header('Content-Type: application/json');
    echo '{"ok":true}';
    exit;
}
unset($_early_action);

// CSRF protection — require X-Requested-With header on all POST requests
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $xrw = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
    if ($xrw !== 'AfrihairApp') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Invalid request origin']);
        exit;
    }
}

// Client databases are resolved from afrihair_master.v2_clients.client_db_name.
// Credentials are loaded from .env — see .env.example for the template.
$db_host = $_ENV['DB_HOST'] ?? 'localhost';
$db_user = $_ENV['DB_USER'] ?? '';
$db_pass = $_ENV['DB_PASS'] ?? '';
$db_name_default = ""; // Do not default to the shared afrihair_clients DB; require explicit client resolution

// Parse JSON payload once so DB routing can use company/action hints.
$raw_input = json_decode(file_get_contents("php://input"), true);
if (!is_array($raw_input)) $raw_input = [];

// Allow CLI callers to pass --company or -c to specify target client slug.
// Use $argv presence + no HTTP_HOST instead of php_sapi_name() === 'cli' so this
// also fires under LiteSpeed PHP (lsphp) which reports sapi = 'litespeed'.
if (isset($argv) && is_array($argv) && !isset($_SERVER['HTTP_HOST'])) {
    foreach ($argv as $i => $arg) {
        if (strpos($arg, '--company=') === 0) {
            $raw_input['company'] = substr($arg, 10);
            break;
        }
        if ($arg === '--company' || $arg === '-c') {
            $val = $argv[$i + 1] ?? null;
            if ($val) { $raw_input['company'] = $val; }
            break;
        }
    }
}

// Keep PHP and MySQL session time in UTC so auth expiry comparisons are consistent.
date_default_timezone_set('UTC');

// Resolve a safe PHP CLI binary path for background exec calls (cPanel-safe).
function get_cli_php_binary(): string {
    $candidates = [];

    if (defined('PHP_BINARY') && is_string(PHP_BINARY) && PHP_BINARY !== '') {
        $candidates[] = PHP_BINARY;
    }

    $which_php = trim((string)@shell_exec('command -v php 2>/dev/null'));
    if ($which_php !== '') {
        $candidates[] = $which_php;
    }

    $candidates[] = '/usr/local/bin/php';
    $candidates[] = '/usr/bin/php';
    $candidates[] = 'php';

    $seen = [];
    foreach ($candidates as $candidate) {
        if (!is_string($candidate) || $candidate === '') continue;
        if (isset($seen[$candidate])) continue;
        $seen[$candidate] = true;

        // Relative command name (e.g. "php") is left to the shell PATH.
        if (strpos($candidate, '/') === false) return $candidate;
        if (@is_executable($candidate)) return $candidate;
    }

    return 'php';
}

// ── Master DB connection (afrihair_master) ────────────────────────
// Read-only queries from the app use this. The master DB is provisioned
// once by running setup_master.php. Falls back gracefully if not yet
// provisioned (e.g. legacy install), so no hard exit here.
$master_db_name = 'afrihair_master';
$master_db = new mysqli($db_host, $db_user, $db_pass, $master_db_name);
if ($master_db->connect_error) {
    // Non-fatal: master DB may not yet exist on legacy installs.
    // Actions that require it will detect $master_db->connect_error and bail cleanly.
    $master_db = null;
} else {
    $master_db->query("SET time_zone = '+00:00'");
    // Ensure rate-limiting table exists in master DB
    $master_db->query("
        CREATE TABLE IF NOT EXISTS `v2_login_attempts` (
            `id` bigint NOT NULL AUTO_INCREMENT,
            `ip_address` varchar(45) NOT NULL,
            `company_slug` varchar(50) NOT NULL DEFAULT '',
            `attempted_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            KEY `idx_ip_time` (`ip_address`, `attempted_at`),
            KEY `idx_cleanup` (`attempted_at`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    ");
}

// Resolve request action/company early so we can route to the correct client DB.
$_boot_action = $_GET['action'] ?? $raw_input['action'] ?? $_GET['cron'] ?? 'get_orders';
$_global_only_actions = ['login', 'verify_client', 'list_shipping_providers', 'list_cms_providers', 'ping'];
$_requires_client_db = !in_array($_boot_action, $_global_only_actions, true);

// ── ping — lightweight connectivity check (exit early, no DB needed) ────────
if ($_boot_action === 'ping') {
    header('Content-Type: application/json');
    echo json_encode(['ok' => true]);
    exit; //to force save
}

function get_request_company_slug(array $input = []): ?string {
    $headers = function_exists('getallheaders') ? getallheaders() : [];
    $company = null;
    foreach ($headers as $k => $v) {
        if (strtolower($k) === 'x-company') { $company = $v; break; }
    }
    if (!$company && isset($_SERVER['HTTP_X_COMPANY'])) $company = $_SERVER['HTTP_X_COMPANY'];
    if (!$company) $company = $_GET['company'] ?? $input['company'] ?? $input['company_slug'] ?? $input['business_slug'] ?? null;
    if (!$company) return null;
    $company = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim((string)$company)));
    return $company !== '' ? $company : null;
}

function generate_client_db_name(string $slug): string {
    $safe = preg_replace('/[^a-z0-9_]/', '_', strtolower($slug));
    $safe = trim($safe, '_');
    if ($safe === '') $safe = 'client';
    $name = 'afrihair_client_' . $safe;
    return substr($name, 0, 64);
}

$_boot_company_slug = get_request_company_slug($raw_input);
$_resolved_client_db = $db_name_default;

if ($master_db && $_boot_company_slug) {
    $mc = $master_db->prepare("SELECT id, client_db_name, is_active FROM v2_clients WHERE slug = ? LIMIT 1");
    $mc->bind_param('s', $_boot_company_slug);
    $mc->execute();
    $mrow = $mc->get_result()->fetch_assoc();

    if ($mrow && (int)$mrow['is_active'] === 1) {
        $_client_db_name = trim((string)($mrow['client_db_name'] ?? ''));

        // During setup, auto-assign and persist a dedicated DB name when missing.
        if ($_client_db_name === '' && $_boot_action === 'run_setup') {
            $_client_db_name = generate_client_db_name($_boot_company_slug);
            $mu = $master_db->prepare("UPDATE v2_clients SET client_db_name = ? WHERE id = ? AND (client_db_name IS NULL OR client_db_name = '')");
            $mu->bind_param('si', $_client_db_name, $mrow['id']);
            $mu->execute();
        }

        if ($_client_db_name !== '') $_resolved_client_db = $_client_db_name;
    }
}

// Ensure target client DB exists for setup requests.
if ($_boot_action === 'run_setup' && $_resolved_client_db !== '') {
    $_admin_db = new mysqli($db_host, $db_user, $db_pass);
    if (!$_admin_db->connect_error) {
        $_safe_db_name = preg_replace('/[^a-z0-9_]/', '_', strtolower($_resolved_client_db));
        if ($_safe_db_name !== '') {
            $_admin_db->query("CREATE DATABASE IF NOT EXISTS `" . $_safe_db_name . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
            $_resolved_client_db = $_safe_db_name;
        }
    }
    unset($_admin_db);
}

$db = null;

// Safety: if we couldn't resolve a client DB name, fail early rather than
// silently writing into a shared/default database, but only for actions that
// actually require a client database.
if ($_requires_client_db) {
    if (empty($_resolved_client_db)) {
        echo json_encode(["error" => "No client DB resolved. Provide X-Company header, ?company parameter, or run setup with a company slug."]);
        exit;
    }

    $db = new mysqli($db_host, $db_user, $db_pass, $_resolved_client_db);
    if ($db->connect_error) {
        $_admin_db = new mysqli($db_host, $db_user, $db_pass);
        if (!$_admin_db->connect_error) {
            $_safe_boot_db = preg_replace('/[^a-z0-9_]/', '_', strtolower($_resolved_client_db));
            if ($_safe_boot_db !== '') {
                $_admin_db->query("CREATE DATABASE IF NOT EXISTS `" . $_safe_boot_db . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
                $db = new mysqli($db_host, $db_user, $db_pass, $_safe_boot_db);
            }
        }
        if ($db->connect_error) {
            echo json_encode(["error" => "DB connection failed"]);
            exit;
        }
    }
    $db->query("SET time_zone = '+00:00'");

    // ====================================================================
    // FIRST-RUN DB MIGRATION — ensures afrihair_clients tables exist
    // ====================================================================
    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_order_locks` (
            `order_id` varchar(50) NOT NULL,
            `staff_name` varchar(100) DEFAULT NULL,
            `last_seen` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`order_id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");

    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_packie_cache` (
            `shopify_order_number` varchar(50) NOT NULL,
            `packie_id` int NOT NULL DEFAULT 0,
            `packie_status` varchar(20) DEFAULT 'PENDING',
            `last_checked` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            `original_shopify_address` text,
            `corrected_address` text,
            PRIMARY KEY (`shopify_order_number`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");
    // Add corrected_address column to existing installations (compatible with MySQL 5.x+).
    // Avoid information_schema dependency and swallow migration errors to prevent API bootstrap failure.
    try {
        $col_check = $db->query("SHOW COLUMNS FROM `v2_packie_cache` LIKE 'corrected_address'");
        if ($col_check && $col_check->num_rows === 0) {
            $db->query("ALTER TABLE `v2_packie_cache` ADD COLUMN `corrected_address` text");
        }
    } catch (Throwable $e) {
        Logger::warn('schema', 'v2_packie_cache corrected_address migration skipped', ['error' => $e->getMessage()]);
    }

    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_packie_stocks` (
            `id` int NOT NULL AUTO_INCREMENT,
            `name` varchar(100) DEFAULT NULL,
            `serviceType` int DEFAULT NULL,
            `height` decimal(10,2) DEFAULT NULL,
            `width` decimal(10,2) DEFAULT NULL,
            `length` decimal(10,2) DEFAULT NULL,
            `weight` decimal(10,2) DEFAULT NULL,
            `cubicWeight` decimal(10,2) DEFAULT NULL,
            PRIMARY KEY (`id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");

    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_picked_items` (
            `id` int NOT NULL AUTO_INCREMENT,
            `order_id` varchar(50) NOT NULL,
            `sku` varchar(100) NOT NULL,
            `picked_qty` int NOT NULL DEFAULT '0',
            `last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            `picked_by` varchar(50) DEFAULT NULL,
            PRIMARY KEY (`id`),
            UNIQUE KEY `unique_pick` (`order_id`, `sku`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");

    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_order_notes` (
            `order_id` varchar(50) NOT NULL,
            `note_text` text NOT NULL,
            `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`order_id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");

    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_staff_members` (
            `id` int NOT NULL AUTO_INCREMENT,
            `name` varchar(255) NOT NULL,
            `is_active` tinyint(1) NOT NULL DEFAULT 1,
            PRIMARY KEY (`id`),
            UNIQUE KEY `unique_name` (`name`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");

    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_settings` (
            `id`            int NOT NULL AUTO_INCREMENT,
            `company_id`    int NOT NULL DEFAULT 1,
            `category`      enum('shipping','ecommerce','general') NOT NULL,
            `provider_slug` varchar(50) NOT NULL,
            `key`           varchar(100) NOT NULL,
            `value`         text NOT NULL,
            `is_encrypted`  tinyint(1) NOT NULL DEFAULT 0,
            `updated_at`    timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `uq_company_cat_provider_key` (`company_id`, `category`, `provider_slug`, `key`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    ");

    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_companies` (
            `id` int NOT NULL AUTO_INCREMENT,
            `slug` varchar(50) NOT NULL,
            `display_name` varchar(100) NOT NULL,
            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (`id`),
            UNIQUE KEY `uq_slug` (`slug`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    ");

    $db->query("
        CREATE TABLE IF NOT EXISTS `v2_auth_sessions` (
            `token` char(64) NOT NULL,
            `staff_id` int NOT NULL,
            `company_id` int NOT NULL,
            `device_name` varchar(100) DEFAULT NULL,
            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
            `expires_at` timestamp NOT NULL,
            PRIMARY KEY (`token`),
            KEY `idx_staff` (`staff_id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    ");

    // Keep V2 bootstrap non-destructive: do not drop legacy/shared tables.

    // Upgrade v2_auth_sessions: add columns that may be missing on older installations
    $_as_cols = [];
    $_as_q = $db->query("SHOW COLUMNS FROM v2_auth_sessions");
    if ($_as_q) while ($_c = $_as_q->fetch_assoc()) $_as_cols[] = $_c['Field'];
    if (!in_array('company_id',   $_as_cols)) $db->query("ALTER TABLE v2_auth_sessions ADD COLUMN `company_id` int NOT NULL DEFAULT 1 AFTER `staff_id`");
    if (!in_array('device_name',  $_as_cols)) $db->query("ALTER TABLE v2_auth_sessions ADD COLUMN `device_name` varchar(100) DEFAULT NULL");
    if (!in_array('expires_at',   $_as_cols)) $db->query("ALTER TABLE v2_auth_sessions ADD COLUMN `expires_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP");
    unset($_as_cols, $_as_q, $_c);

    // Upgrade v2_staff_members: add new columns if this is an existing installation
    $_sm_cols = [];
    $_sm_q = $db->query("SHOW COLUMNS FROM v2_staff_members");
    while ($_c = $_sm_q->fetch_assoc()) $_sm_cols[] = $_c['Field'];
    if (!in_array('company_id',    $_sm_cols)) $db->query("ALTER TABLE v2_staff_members ADD COLUMN `company_id` int NOT NULL DEFAULT 1 AFTER `id`");
    if (!in_array('username',      $_sm_cols)) $db->query("ALTER TABLE v2_staff_members ADD COLUMN `username` varchar(100) NULL");
    if (!in_array('password_hash', $_sm_cols)) $db->query("ALTER TABLE v2_staff_members ADD COLUMN `password_hash` varchar(255) NULL");
    if (!in_array('role',          $_sm_cols)) $db->query("ALTER TABLE v2_staff_members ADD COLUMN `role` enum('master','secondary') NOT NULL DEFAULT 'secondary'");
    if (!in_array('can_pick',      $_sm_cols)) $db->query("ALTER TABLE v2_staff_members ADD COLUMN `can_pick` tinyint(1) NOT NULL DEFAULT 1");
    if (!in_array('can_pack',      $_sm_cols)) $db->query("ALTER TABLE v2_staff_members ADD COLUMN `can_pack` tinyint(1) NOT NULL DEFAULT 1");
    if (!in_array('can_print',     $_sm_cols)) {
        $db->query("ALTER TABLE v2_staff_members ADD COLUMN `can_print` tinyint(1) NOT NULL DEFAULT 0");
        // Grant print access to all existing master accounts — they're admins and should always be able to print.
        $db->query("UPDATE v2_staff_members SET can_print = 1 WHERE role = 'master'");
    }
    if (!in_array('last_login',    $_sm_cols)) $db->query("ALTER TABLE v2_staff_members ADD COLUMN `last_login` timestamp NULL DEFAULT NULL");
    unset($_sm_cols, $_sm_q, $_c);

    // Upgrade v2_settings: scope provider v2_settings by company_id on existing installations
        // Always ensure master accounts have print access — safe to run on every boot
        // since it only touches rows where can_print is already wrong (0 on a master).
        $db->query("UPDATE v2_staff_members SET can_print = 1 WHERE role = 'master' AND can_print = 0");

    $_st_cols = [];
    $_st_q = $db->query("SHOW COLUMNS FROM v2_settings");
    if ($_st_q) while ($_c = $_st_q->fetch_assoc()) $_st_cols[] = $_c['Field'];
    if (!in_array('company_id', $_st_cols)) {
        $db->query("ALTER TABLE v2_settings ADD COLUMN `company_id` int NOT NULL DEFAULT 1 AFTER `id`");
    }

    $_st_has_old_uq = false;
    $_st_has_new_uq = false;
    $_st_ix_q = $db->query("SHOW INDEX FROM v2_settings");
    if ($_st_ix_q) {
        while ($_ix = $_st_ix_q->fetch_assoc()) {
            if (($_ix['Key_name'] ?? '') === 'uq_cat_provider_key') $_st_has_old_uq = true;
            if (($_ix['Key_name'] ?? '') === 'uq_company_cat_provider_key') $_st_has_new_uq = true;
        }
    }
    if ($_st_has_old_uq) $db->query("ALTER TABLE v2_settings DROP INDEX `uq_cat_provider_key`");
    if (!$_st_has_new_uq) {
        $db->query("ALTER TABLE v2_settings ADD UNIQUE KEY `uq_company_cat_provider_key` (`company_id`, `category`, `provider_slug`, `key`)");
    }
    unset($_st_cols, $_st_q, $_st_ix_q, $_st_has_old_uq, $_st_has_new_uq, $_ix, $_c);
}

// ====================================================================
// PROVIDER MANAGER
// ====================================================================
// Reads ALL config from the `v2_settings` table in afrihair_clients.
// Keys/tokens are NEVER written to disk; bearer_token is stored in v2_settings.
// ====================================================================
class ProviderManager {
    private $db;
    private int $company_id;
    /** @var array<string,string> Flat key=>value map for active shipping provider */
    private array $ship   = [];
    /** @var array<string,string> Flat key=>value map for active ecommerce provider */
    private array $ecom   = [];
    private string $ship_slug = 'packie';
    private string $ecom_slug = '';

    public function getShipSlug(): string { return $this->ship_slug; }
    public function getEcomSlug(): string  { return $this->ecom_slug; }

    public function __construct($db, int $company_id = 1) {
        $this->db         = $db;
        $this->company_id = $company_id;
        $this->load();
    }

    private function load(): void {
        // Read active provider slugs
        $stmt = $this->db->prepare("
            SELECT provider_slug, `key`, `value` FROM v2_settings
            WHERE company_id = ? AND category = 'general' AND provider_slug = 'app'
        ");
        $stmt->bind_param('i', $this->company_id);
        $stmt->execute();
        $res = $stmt->get_result();
        $general = [];
        if ($res) while ($r = $res->fetch_assoc()) $general[$r['key']] = $r['value'];
        if (!empty($general['active_shipping_provider'])) $this->ship_slug = $general['active_shipping_provider'];
        if (array_key_exists('active_ecom_provider', $general)) {
            $candidate = trim((string)$general['active_ecom_provider']);
            $this->ecom_slug = in_array(strtolower($candidate), ['', 'none', 'disabled', 'off'], true) ? '' : $candidate;
        }

        // Load all v2_settings rows for both active providers
        $stmt2 = $this->db->prepare("
            SELECT category, `key`, `value` FROM v2_settings
            WHERE company_id = ?
              AND ((category = 'shipping'   AND provider_slug = ?)
                OR (category = 'ecommerce'  AND provider_slug = ?))
        ");
        $stmt2->bind_param('iss', $this->company_id, $this->ship_slug, $this->ecom_slug);
        $stmt2->execute();
        $res2 = $stmt2->get_result();
        if ($res2) while ($r = $res2->fetch_assoc()) {
            if ($r['category'] === 'shipping')   $this->ship[$r['key']] = $r['value'];
            if ($r['category'] === 'ecommerce')  $this->ecom[$r['key']] = $r['value'];
        }
    }

    // ------------------------------------------------------------------
    // v2_settings write helper — used by run_setup and save_provider_config
    // ------------------------------------------------------------------
    public function saveSetting(string $category, string $provider_slug, string $key, string $value): void {
        $stmt = $this->db->prepare("
            INSERT INTO v2_settings (company_id, category, provider_slug, `key`, `value`)
            VALUES (?, ?, ?, ?, ?)
            ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)
        ");
        $stmt->bind_param('issss', $this->company_id, $category, $provider_slug, $key, $value);
        $stmt->execute();
    }

    // ------------------------------------------------------------------
    // Ecommerce helpers
    // ------------------------------------------------------------------

    /** Expose raw ecommerce config so adapter transport functions can read credentials. */
    public function getEcomConfig(): array {
        return array_merge($this->ecom, ['_slug' => $this->ecom_slug]);
    }

    /**
     * Delegate to the ecommerce adapter's ecommerce_http_call() function.
     * Each adapter registers that function when included; returns null when no adapter is loaded.
     */
    public function ecomCall(string $method, string $endpoint, array $data = []): ?array {
        if (function_exists('ecommerce_http_call')) {
            return ecommerce_http_call($method, $endpoint, $data);
        }
        return null;
    }

    public function ecomGraphql(string $query, array $variables = []): ?array {
        if (function_exists('ecommerce_graphql_call')) {
            return ecommerce_graphql_call($query, $variables);
        }
        return null;
    }

    // ------------------------------------------------------------------
    // Shipping helpers
    // ------------------------------------------------------------------
    private function shipBaseUrl(): string {
        return rtrim($this->ship['base_url'] ?? 'https://www.packie.co.nz/api', '/');
    }
    private function shipProvider(): string {
        return $this->ship_slug;
    }
    public function senderDetails(): array {
        return [
            'contactName'   => $this->ship['sender_contact_name'] ?? 'Manager',
            'company'       => $this->ship['sender_company']      ?? '',
            'streetAddress' => $this->ship['sender_street']       ?? '',
            'city'          => $this->ship['sender_city']         ?? '',
            'postCode'      => $this->ship['sender_postcode']     ?? '',
            'country'       => $this->ship['sender_country']      ?? 'NZ',
        ];
    }

    public function refreshShipToken(): bool {
        $provider = $this->shipProvider();
        $base     = $this->shipBaseUrl();

        if ($provider === 'packie') {
            $username = $this->ship['username'] ?? '';
            $password = $this->ship['api_key']  ?? '';
            if (!$username || !$password) return false;

            $ch = curl_init($base . '/users/login');
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['username' => $username, 'password' => $password, 'remember' => true, 'shopify' => null]));
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json']);
            $response  = curl_exec($ch);
            $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);

            if ($http_code === 200 || $http_code === 201) {
                $data  = json_decode($response, true);
                $token = $data['result']['token'] ?? null;
                if ($token) {
                    // Store token in DB — never in a file
                    $this->saveSetting('shipping', $provider, 'bearer_token', $token);
                    $this->ship['bearer_token'] = $token;
                    return true;
                }
            }
            return false;
        }
        // CourierIT / others: extend here
        return false;
    }

    public function getShipToken(): ?string {
        // Read from DB (already loaded into $this->ship)
        $token = $this->ship['bearer_token'] ?? '';
        return $token !== '' ? $token : null;
    }

    public function getShipHeaders(): ?array {
        $token = $this->getShipToken();
        if (!$token) return null;
        // All current providers use Bearer auth
        return ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $token];
    }

    public function shipUrl(string $path): string {
        return $this->shipBaseUrl() . '/' . ltrim($path, '/');
    }

  
public function detectShipStatus(string $street, string $suburb, string $city, string $postcode, string $country = 'NZ', string $debug_order = '', int $packie_order_id = 0): string {
    $debug     = ($debug_order === '17127');
    //$debug = true; // TESTING — remove or condition this in production
    // Local validation runs first — before any API call or token check.
    $street   = trim($street);
    $suburb   = trim($suburb);
    $city     = trim($city);
    $postcode = trim($postcode);
    $country  = (strlen($country) > 2) ? 'NZ' : strtoupper($country);

    if ($street === '' && $suburb !== '') { $street = $suburb; $suburb = ''; }
    if ($street === '' || $city === '') {
        if ($debug) Logger::debug('detectShipStatus', 'INVALID (local check)', ['street' => $street, 'suburb' => $suburb, 'city' => $city]);
        return 'INVALID';
    }

    $headers  = $this->getShipHeaders();
    if (!$headers) {
        if ($debug) Logger::debug('detectShipStatus', 'READY (no bearer token)', ['street' => $street, 'city' => $city]);
        return 'READY'; // token unavailable — local checks passed, can't do rural check
    }
    $provider = $this->shipProvider();

    if ($provider === 'packie') {
        // Use real stock dimensions from v2_packie_stocks so Packie can evaluate rates.
        // Fall back to a generic A4-pack size if the table is empty.
        // Default: NZ Post Courier Pack A4 dimensions — known to work with Packie calculate.
        // Prefer serviceType=4 stock from DB; fall back to these hardcoded safe values.
        $parcel = ['id' => '4', 'height' => '29.7', 'width' => '1', 'length' => '21', 'weight' => '1', 'quantity' => '1', 'serviceType' => 4];
        $stock_row = $this->db->query("SELECT id, height, width, length, weight, serviceType FROM v2_packie_stocks WHERE serviceType = 4 AND height > 0 AND weight > 0 ORDER BY id ASC LIMIT 1");
        if ($stock_row && $s = $stock_row->fetch_assoc()) {
            $parcel = [
                'id'          => (string)$s['id'],
                'height'      => (string)$s['height'],
                'width'       => (string)$s['width'],
                'length'      => (string)$s['length'],
                'weight'      => (string)$s['weight'],
                'quantity'    => '1',
                'serviceType' => (int)$s['serviceType'],
            ];
        }

        $payload = [
            'consignmentParcels'  => [$parcel],
            'hasDangerousGoods'   => false,
            'integrationType'     => 1,
            'isInternational'     => ($country !== 'NZ'),
            'receiver'            => ['suburb' => $suburb, 'city' => $city, 'country' => $country, 'streetAddress' => $street, 'postCode' => $postcode],
            'sender'              => $this->senderDetails(),
            'shippingDescription' => 'Fastest Delivery',
            'shippingMethod'      => 'Fastest Delivery',
            'signatureRequired'   => false,
        ];
        if ($packie_order_id > 0) {
            $payload['integratedOrderId'] = $packie_order_id;
        }

        if ($debug) Logger::debug('detectShipStatus', 'Packie calculate REQUEST', ['payload' => $payload]);

        $ch = curl_init($this->shipUrl('Consignments/calculate'));
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_TIMEOUT, 5);
        $raw_res   = curl_exec($ch);
        $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($debug) Logger::debug('detectShipStatus', 'Packie calculate RESPONSE', ['http_code' => $http_code, 'body' => $raw_res]);

        $res = json_decode($raw_res, true);

        // Curl/parse failure — be lenient, don't flag as invalid.
        if ($res === null || !isset($res['result']['rates'])) {
            if ($debug) Logger::debug('detectShipStatus', 'READY (null/no rates key — curl or parse failure)');
            return 'READY';
        }
        // Packie responded but returned no rates — address is not serviceable.
        if (empty($res['result']['rates'])) {
            if ($debug) Logger::debug('detectShipStatus', 'INVALID (Packie returned empty rates array)');
            return 'INVALID';
        }
        foreach ($res['result']['rates'] as $rate) {
            if (!empty($rate['rural_rate']) && (float)$rate['rural_rate'] > 0) {
                if ($debug) Logger::debug('detectShipStatus', 'RURAL (rural_rate found)', ['rural_rate' => $rate['rural_rate']]);
                return 'RURAL';
            }
            if (!empty($rate['is_rural']) && $rate['is_rural'] == true) {
                if ($debug) Logger::debug('detectShipStatus', 'RURAL (is_rural=true)');
                return 'RURAL';
            }
        }
        if ($debug) Logger::debug('detectShipStatus', 'READY (rates returned, no rural flags)');
        return 'READY';
    }
    return 'READY';
}
} // end class ProviderManager


// ====================================================================
// UNIVERSAL ROUTER
// ====================================================================
// Detect CLI mode by $argv presence + no HTTP_HOST rather than php_sapi_name()
// so the router works under LiteSpeed PHP (lsphp) which returns 'litespeed'.
if (isset($argv) && is_array($argv) && !isset($_SERVER['HTTP_HOST'])) {
    // Normalize CLI args: consume known --company/-c options first so the
    // remaining argv values map to action and positional args.
    $cli_args = $argv;
    array_shift($cli_args); // remove script name

    // Consume --company=slug or --company slug or -c slug
    foreach ($cli_args as $k => $v) {
        if (is_string($v) && strpos($v, '--company=') === 0) {
            $raw_input['company'] = substr($v, 10);
            unset($cli_args[$k]);
            continue;
        }
        if ($v === '--company' || $v === '-c') {
            $val = $cli_args[$k + 1] ?? null;
            if ($val) {
                $raw_input['company'] = $val;
                unset($cli_args[$k], $cli_args[$k + 1]);
            }
            break;
        }
    }
    $cli_args = array_values($cli_args);

    $action = $cli_args[0] ?? 'default';
    $input = [];
    $arg_oid = $cli_args[1] ?? null;
    $arg_staff = $cli_args[2] ?? 'Staff';
    $arg_stock = $cli_args[3] ?? 'none';
    $arg_rate = $cli_args[4] ?? 'none';
    $arg_w = $cli_args[5] ?? 0;
    $arg_l = $cli_args[6] ?? 0;
    $arg_h = $cli_args[7] ?? 0;
    $arg_weight = $cli_args[8] ?? 0;
    $arg_order_name = $cli_args[9] ?? '';
} else {
    $input = $raw_input;
    $action = $_GET['action'] ?? $input['action'] ?? $_GET['cron'] ?? 'get_orders';
}

$pm = null;
if ($_requires_client_db) {
    // ------------------------------------------------------------------
    // Resolve company_id for this request:
    //   - Authenticated requests: use the token's company_id
    //   - Cron / unauthenticated requests: fall back to company 1
    // ------------------------------------------------------------------
    $_req_company_id = 1;
    $_req_token = get_request_auth_token();
    if ($_req_token) {
        $_tok_stmt = $db->prepare("SELECT company_id FROM v2_auth_sessions WHERE token = ? AND expires_at > UTC_TIMESTAMP()");
        $_tok_stmt->bind_param('s', $_req_token);
        $_tok_stmt->execute();
        $_tok_row = $_tok_stmt->get_result()->fetch_assoc();
        if ($_tok_row) $_req_company_id = (int)$_tok_row['company_id'];
        unset($_tok_stmt, $_tok_row);
    }
    unset($_req_token);

    // Initialise the global ProviderManager — used by all wrapper functions below
    $pm = new ProviderManager($db, $_req_company_id);

    // ------------------------------------------------------------------
    // Ecommerce wrappers (available to both carrier and ecommerce adapters)
    // ------------------------------------------------------------------
    function ecom_call($method, $endpoint, $data = []) {
        global $pm;
        return $pm->ecomCall($method, $endpoint, $data);
    }
    function ecom_graphql($query, $variables = []) {
        global $pm;
        return $pm->ecomGraphql($query, $variables);
    }

    // -- Load carrier adapter (defines carrier-specific helpers) --
    $_ship = $pm->getShipSlug();
    if (file_exists(__DIR__ . '/courier/' . $_ship . '.php')) include __DIR__ . '/courier/' . $_ship . '.php';

    // -- Load ecommerce adapter --
    $_ecom = $pm->getEcomSlug();
    if (file_exists(__DIR__ . '/ecommerce/' . $_ecom . '.php')) include __DIR__ . '/ecommerce/' . $_ecom . '.php';
}

// ====================================================================
// AUTH HELPER
// ====================================================================
function get_request_auth_token(): ?string {
    $headers = function_exists('getallheaders') ? getallheaders() : [];
    $token = null;
    foreach ($headers as $k => $v) {
        if (strtolower($k) === 'x-auth-token') { $token = $v; break; }
    }
    if (!$token && isset($_SERVER['HTTP_X_AUTH_TOKEN'])) $token = $_SERVER['HTTP_X_AUTH_TOKEN'];
    if (!$token && isset($_SERVER['REDIRECT_HTTP_X_AUTH_TOKEN'])) $token = $_SERVER['REDIRECT_HTTP_X_AUTH_TOKEN'];
    if (!$token && isset($_SERVER['HTTP_AUTHORIZATION'])) {
        $auth = trim($_SERVER['HTTP_AUTHORIZATION']);
        if (stripos($auth, 'Bearer ') === 0) $token = trim(substr($auth, 7));
    }
    if (!$token) $token = $_GET['_token'] ?? null;
    if (!$token) return null;

    $token = preg_replace('/[^a-f0-9]/', '', strtolower(trim($token)));
    return strlen($token) === 64 ? $token : null;
}

function get_current_user_from_request($db) {
    $token = get_request_auth_token();
    if (!$token) return null;

    // Step 1: validate active session first.
    $s_stmt = $db->prepare("SELECT staff_id, company_id FROM v2_auth_sessions WHERE token = ? AND expires_at > UTC_TIMESTAMP() LIMIT 1");
    $s_stmt->bind_param("s", $token);
    $s_stmt->execute();
    $session = $s_stmt->get_result()->fetch_assoc();
    if (!$session) {
        Logger::info('auth.session_expired', 'Invalid or expired session token', ['token_prefix' => substr($token, 0, 8)]);
        return null;
    }

    $staff_id = (int)$session['staff_id'];
    $company_id = (int)$session['company_id'];

    // Step 2: resolve staff record. Use company_id from session for extra safety.
    $u_stmt = $db->prepare("SELECT id, name, username, role, can_pick, can_pack, can_print, company_id, is_active FROM v2_staff_members WHERE id = ? AND company_id = ? LIMIT 1");
    $u_stmt->bind_param("ii", $staff_id, $company_id);
    $u_stmt->execute();
    $user = $u_stmt->get_result()->fetch_assoc();

    // Return the real staff row regardless of is_active — active check belongs at login,
    // not here. A valid session token means the user is authenticated.
    if ($user) return $user;

    // Staff row genuinely missing (deleted after session was created) — minimal stub.
    return [
        'id' => $staff_id,
        'name' => 'Session User',
        'username' => '',
        'role' => 'secondary',
        'can_pick' => 0,
        'can_pack' => 0,
        'can_print' => 0,
        'company_id' => $company_id,
        'is_active' => 0,
    ];
}

// ====================================================================
// 2. APP API ENDPOINTS
// ====================================================================

// Client-side click logging from the React app. Stores a simple JSON
// blob to `client_clicks.log` for debugging UI interactions.
if ($action === 'client_click_log') {
    $user = get_current_user_from_request($db);
    if (!$user) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }
    $entry = [
        'ts' => gmdate('c'),
        'company' => $_boot_company_slug ?? null,
        'user' => $user['name'] ?? null,
        'payload' => $input,
        'remote' => $_SERVER['REMOTE_ADDR'] ?? null,
    ];
    Logger::info('client_click', 'User click event', $entry);
    echo json_encode(['success' => true]);
    exit;
}

if ($action == 'get_orders') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }
    if (function_exists('ecommerce_get_orders')) {
        echo json_encode(ecommerce_get_orders());
        exit;
    }
    if (function_exists('shipping_get_orders')) {
        echo json_encode(shipping_get_orders());
        exit;
    }
    echo json_encode([]);
    exit;
}

if ($action == 'get_sender_details') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }

    global $pm;
    $sender = $pm ? $pm->senderDetails() : [
        'contactName'   => 'Manager',
        'company'       => 'Afrihair Ltd',
        'streetAddress' => '247 Cuba Street',
        'city'          => 'Palmerston North',
        'postCode'      => '4410',
        'country'       => 'NZ',
    ];

    echo json_encode(['success' => true, 'sender' => $sender]);
    exit;
}

if ($action == 'detect_ship_status') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }

    global $pm;
    if (!$pm) {
        echo json_encode(['success' => false, 'error' => 'Provider manager unavailable']);
        exit;
    }

    $street   = trim((string)($input['street'] ?? ''));
    $suburb   = trim((string)($input['suburb'] ?? ''));
    $city     = trim((string)($input['city'] ?? ''));
    $postcode = trim((string)($input['postcode'] ?? ''));
    $country  = trim((string)($input['country'] ?? 'NZ'));
    $order_name = trim((string)($input['order_name'] ?? ''));
    $shopify_num = str_replace('#', '', $order_name);

    $status = $pm->detectShipStatus($street, $suburb, $city, $postcode, $country, $shopify_num);

    if ($shopify_num !== '') {
        $stmt = $db->prepare("UPDATE v2_packie_cache SET packie_status = ? WHERE shopify_order_number = ?");
        $stmt->bind_param('ss', $status, $shopify_num);
        $stmt->execute();
    }

    echo json_encode(['success' => true, 'packie_status' => $status]);
    exit;
}

if ($action == 'add_note') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }

    $order_id  = trim((string)($input['order_id'] ?? ''));
    $note_text = trim(preg_replace('/[^\pL\pN\s\-\#\.\,\:\(\)\/]/u', '', (string)($input['note'] ?? '')));
    if ($order_id === '') {
        echo json_encode(['success' => false, 'error' => 'order_id is required']);
        exit;
    }

    // Always persist locally first.
    $stmt = $db->prepare("INSERT INTO v2_order_notes (order_id, note_text) VALUES (?, ?) ON DUPLICATE KEY UPDATE note_text = VALUES(note_text)");
    $stmt->bind_param('ss', $order_id, $note_text);
    $stmt->execute();

    $remote_synced = false;
    $remote_error = null;
    if (function_exists('ecommerce_sync_note')) {
        try {
            $sync = ecommerce_sync_note($order_id, $note_text);
            $remote_synced = !empty($sync['success']);
            if (!$remote_synced) $remote_error = $sync['error'] ?? 'Remote note sync failed';
        } catch (Throwable $e) {
            $remote_error = $e->getMessage();
        }
    }

    echo json_encode([
        'success' => true,
        'local_saved' => true,
        'remote_synced' => $remote_synced,
        'remote_error' => $remote_error,
    ]);
    exit;
}

if ($action == 'mark_packed') {
    $caller = get_current_user_from_request($db);
    if (!$caller) { http_response_code(401); echo json_encode(['success' => false, 'error' => 'Unauthorized']); exit; }

    $order_id    = trim((string)($input['order_id'] ?? ''));
    $order_name  = trim((string)($input['order_name'] ?? ''));
    $shopify_num = str_replace('#', '', $order_name);
    $staff       = trim((string)($input['staff'] ?? '?'));
    $force_override = (int)($input['force_override'] ?? 0);

    $existing_lock_info = '';
    if ($force_override) {
        $lock_check_stmt = $db->prepare("SELECT staff_name FROM v2_order_locks WHERE order_id = ?");
        $lock_check_stmt->bind_param('s', $order_id);
        $lock_check_stmt->execute();
        $lock_row = $lock_check_stmt->get_result()->fetch_assoc();
        if ($lock_row) $existing_lock_info = ' (forced override from: ' . $lock_row['staff_name'] . ')';
    }

    Logger::info('mark_packed', 'start', ['order_id' => $order_id, 'order' => $shopify_num, 'staff' => $staff, 'stock' => ($input['stock_id'] ?? 'none'), 'rate' => ($input['rate_id'] ?? 'none'), 'company' => ($_boot_company_slug ?? ''), 'lock_info' => $existing_lock_info]);
    Logger::info('mark_packed', 'ui_debug', ['packie_status' => ($input['ui_packie_status'] ?? ''), 'is_pickup' => (int)($input['ui_is_pickup'] ?? 0), 'user_role' => ($input['ui_user_role'] ?? ''), 'user_can_print' => (int)($input['ui_user_can_print'] ?? 0), 'preferred_method' => ($input['ui_preferred_print_method'] ?? ''), 'is_print_attempt' => (int)($input['ui_is_print_attempt'] ?? 0)]);

    $s1 = $db->prepare("DELETE FROM v2_order_locks WHERE order_id = ?");
    $s1->bind_param("s", $order_id); $s1->execute();

    $s2 = $db->prepare("DELETE FROM v2_picked_items WHERE order_id = ?");
    $s2->bind_param("s", $order_id); $s2->execute();

    if ($shopify_num !== '') {
        $s3 = $db->prepare("UPDATE v2_packie_cache SET packie_status = 'PRINTED' WHERE shopify_order_number = ?");
        $s3->bind_param("s", $shopify_num); $s3->execute();
        Logger::info('mark_packed', 'cache_update', ['affected_rows' => $s3->affected_rows]);
    }

    $args = escapeshellarg($order_id) . ' '
        . escapeshellarg($input['staff']    ?? 'Staff') . ' '
        . escapeshellarg($input['stock_id'] ?? 'none')  . ' '
        . escapeshellarg($input['rate_id']  ?? 'none')  . ' '
        . escapeshellarg($input['w']        ?? 0)       . ' '
        . escapeshellarg($input['l']        ?? 0)       . ' '
        . escapeshellarg($input['h']        ?? 0)       . ' '
        . escapeshellarg($input['weight']   ?? 0)       . ' '
        . escapeshellarg($order_name);

    $is_print_attempt = (int)($input['ui_is_print_attempt'] ?? 0);
    Logger::info('mark_packed', 'print_decision', [
        'is_print_attempt' => $is_print_attempt,
        'stock_id' => ($input['stock_id'] ?? 'null'),
        'rate_id' => ($input['rate_id'] ?? 'null'),
        'packie_status' => ($input['ui_packie_status'] ?? 'null'),
        'is_pickup' => (int)($input['ui_is_pickup'] ?? 0),
        'can_print' => (int)($input['ui_user_can_print'] ?? 0),
        'preferred_method' => ($input['ui_preferred_print_method'] ?? 'null'),
    ]);
    echo json_encode(['success' => true]);

    if (!$is_print_attempt) {
        Logger::info('mark_packed', 'pack_only skipping print_label');
        exit;
    }

    // Flush the success response to the app before starting any courier work so
    // the order detail screen can close immediately even if label creation is slow.
    ignore_user_abort(true);
    if (function_exists('fastcgi_finish_request')) {
        fastcgi_finish_request();
    } else {
        while (ob_get_level() > 0) {
            @ob_end_flush();
        }
        @flush();
    }

    $company_arg = escapeshellarg($_boot_company_slug ?? '');
    $php_bin = get_cli_php_binary();
    $api_script = __DIR__ . '/api.php';
    $php_bin_esc = escapeshellarg($php_bin);
    $api_script_esc = escapeshellarg($api_script);
    $null_device = stripos(PHP_OS, 'WIN') === 0 ? 'NUL' : '/dev/null';
    // mark_packed now lives in api.php, so use __DIR__ (not dirname(__DIR__))
    // to target this same API file for the async print_label CLI call.
    $cmd_line = $php_bin_esc . ' ' . $api_script_esc . " --company=$company_arg print_label $args";
    Logger::info('mark_packed', 'php_bin', ['php_bin' => $php_bin]);
    if (strpos($php_bin, '/') !== false && !@is_executable($php_bin)) {
        Logger::warn('mark_packed', 'php_binary_not_executable', ['php_bin' => $php_bin]);
    }
    Logger::info('mark_packed', 'exec', ['cmd_line' => $cmd_line]);

    $spawn_output = [];
    $spawn_code = 0;
    $detach_cmd = 'nohup ' . $cmd_line . ' > ' . $null_device . ' 2>&1 < ' . $null_device . ' & echo $!';
    exec($detach_cmd, $spawn_output, $spawn_code);
    $spawn_pid = trim((string)($spawn_output[0] ?? ''));
    Logger::info('mark_packed', 'spawn_result', ['exit_code' => $spawn_code, 'pid' => $spawn_pid !== '' ? $spawn_pid : 'unknown']);

    if ($spawn_code !== 0 || $spawn_pid === '') {
        Logger::warn('mark_packed', 'sync_fallback attempting synchronous print_label');
        $sync_output = [];
        $sync_code = 0;
        exec($cmd_line . ' 2>&1', $sync_output, $sync_code);
        Logger::info('mark_packed', 'sync_result', ['exit_code' => $sync_code, 'output' => implode("\n", $sync_output)]);
    }
    exit;
}

if ($action == 'update_pick') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }
    $order_id = trim($input['order_id'] ?? '');
    $sku      = trim($input['sku'] ?? '');
    $qty      = (int)($input['qty'] ?? 0);
    $staff    = trim($input['staff'] ?? '');

    if ($order_id === '' || $sku === '') {
        echo json_encode(['success' => false, 'error' => 'order_id and sku are required']);
        exit;
    }

    $stmt = $db->prepare("
        INSERT INTO v2_picked_items (order_id, sku, picked_qty, picked_by)
        VALUES (?, ?, ?, ?)
        ON DUPLICATE KEY UPDATE picked_qty = ?, picked_by = ?
    ");
    $stmt->bind_param("ssisis", $order_id, $sku, $qty, $staff, $qty, $staff);
    $stmt->execute();

    echo json_encode(['success' => true]);
    exit;
}

if ($action == 'get_picks') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }
    $res = $db->query("SELECT order_id, sku, picked_qty, picked_by FROM v2_picked_items");
    $picks = [];

    while ($row = $res->fetch_assoc()) {
        if (!isset($picks[$row['order_id']])) $picks[$row['order_id']] = [];
        $picks[$row['order_id']][$row['sku']] = [
            'qty' => (int)$row['picked_qty'],
            'by' => $row['picked_by']
        ];
    }

    echo json_encode($picks);
    exit;
}

if ($action == 'check_lock') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }
    $order_id = trim($_GET['order_id'] ?? '');
    $staff    = trim($_GET['staff'] ?? '');
    if ($order_id === '' || $staff === '') {
        echo json_encode(['success' => false, 'error' => 'order_id and staff are required']);
        exit;
    }

    $stmt = $db->prepare("SELECT staff_name FROM v2_order_locks WHERE order_id = ? AND staff_name != ?");
    $stmt->bind_param("ss", $order_id, $staff);
    $stmt->execute();
    $res = $stmt->get_result()->fetch_assoc();

    if ($res) {
        echo json_encode(["locked_by" => $res['staff_name']]);
    } else {
        $stmt = $db->prepare("
            INSERT INTO v2_order_locks (order_id, staff_name)
            VALUES (?, ?)
            ON DUPLICATE KEY UPDATE staff_name = ?, last_seen = NOW()
        ");
        $stmt->bind_param("sss", $order_id, $staff, $staff);
        $stmt->execute();
        echo json_encode(["status" => "locked", "locked_by" => null]);
    }
    exit;
}

if ($action == 'release_lock') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }
    $order_id = trim($_GET['order_id'] ?? '');
    $staff    = trim($_GET['staff'] ?? '');
    if ($order_id === '' || $staff === '') {
        echo json_encode(['success' => false, 'error' => 'order_id and staff are required']);
        exit;
    }

    $stmt = $db->prepare("DELETE FROM v2_order_locks WHERE order_id = ? AND staff_name = ?");
    $stmt->bind_param("ss", $order_id, $staff);
    $stmt->execute();

    echo json_encode(["status" => "released"]);
    exit;
}

// ====================================================================
// STAFF MANAGEMENT ENDPOINTS
// ====================================================================

if ($action == 'get_staff') {
    $caller = get_current_user_from_request($db);
    if (!$caller) {
        http_response_code(401);
        echo json_encode(['success' => false, 'error' => 'Unauthorized']);
        exit;
    }
    $cid = (int)$caller['company_id'];
    $stmt = $db->prepare("SELECT id, name, role, can_pick, can_pack, can_print, is_active FROM v2_staff_members WHERE company_id = ? ORDER BY name ASC");
    $stmt->bind_param('i', $cid);
    $stmt->execute();
    $res = $stmt->get_result();
    $staff = [];
    while ($row = $res->fetch_assoc()) {
        $staff[] = [
            'id'        => (int)$row['id'],
            'name'      => $row['name'],
            'role'      => $row['role'],
            'can_pick'  => (bool)$row['can_pick'],
            'can_pack'  => (bool)$row['can_pack'],
            'can_print' => (bool)$row['can_print'],
            'is_active' => (bool)$row['is_active'],
        ];
    }
    echo json_encode(['staff' => $staff]);
    exit;
}

if ($action == 'add_staff') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }
    $name = trim($input['name'] ?? '');
    if ($name === '') {
        echo json_encode(['success' => false, 'error' => 'Name is required']);
        exit;
    }
    $company_id = (int)$caller['company_id'];
    $stmt = $db->prepare("
        INSERT INTO v2_staff_members (company_id, name, is_active, role, can_pick, can_pack, can_print)
        VALUES (?, ?, 1, 'secondary', 1, 1, 0)
    ");
    $stmt->bind_param("is", $company_id, $name);
    if (!$stmt->execute()) {
        echo json_encode(['success' => false, 'error' => 'Could not add staff (name may already exist).']);
        exit;
    }
    echo json_encode(['success' => true, 'id' => (int)$db->insert_id]);
    exit;
}


if ($action == 'delete_staff') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }

    $id = (int)($input['id'] ?? 0);
    if ($id <= 0) {
        echo json_encode(['success' => false, 'error' => 'Invalid id']);
        exit;
    }

    $company_id = (int)$caller['company_id'];

    // Ensure we don't delete the last active master
    $roleStmt = $db->prepare("SELECT role FROM v2_staff_members WHERE id = ? AND company_id = ? LIMIT 1");
    $roleStmt->bind_param("ii", $id, $company_id);
    $roleStmt->execute();
    $roleRow = $roleStmt->get_result()->fetch_assoc();
    $roleStmt->close();

    if ($roleRow && ($roleRow['role'] === 'master')) {
        $cntStmt = $db->prepare("SELECT COUNT(*) AS masters FROM v2_staff_members WHERE company_id = ? AND role = 'master' AND is_active = 1");
        $cntStmt->bind_param("i", $company_id);
        $cntStmt->execute();
        $cnt = (int)$cntStmt->get_result()->fetch_assoc()['masters'];
        $cntStmt->close();

        if ($cnt <= 1) {
            echo json_encode(['success' => false, 'error' => 'Cannot delete the last master account']);
            exit;
        }
    }

    // Delete staff row
    $stmt = $db->prepare("DELETE FROM v2_staff_members WHERE id = ? AND company_id = ?");
    $stmt->bind_param("ii", $id, $company_id);
    if (!$stmt->execute()) {
        echo json_encode(['success' => false, 'error' => 'Delete failed: ' . $stmt->error]);
        exit;
    }
    if ($stmt->affected_rows === 0) {
        echo json_encode(['success' => false, 'error' => 'Staff member not found.']);
        exit;
    }
    $stmt->close();

    // Remove any active sessions for that staff (cleanup orphaned sessions)
    $sdel = $db->prepare("DELETE FROM v2_auth_sessions WHERE staff_id = ? AND company_id = ?");
    $sdel->bind_param("ii", $id, $company_id);
    $sdel->execute();
    $sdel->close();

    Logger::warn('staff.delete', 'Staff member deleted', ['staff_id' => $id, 'by' => $caller['name'] ?? 'unknown']);

    echo json_encode(['success' => true]);
    exit;
}

// ====================================================================
// AUTH + SETUP ENDPOINTS
// ====================================================================

if ($action == 'check_setup') {
    $slug = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($_GET['company'] ?? '')));
    if ($slug === '') { echo json_encode(['exists' => false, 'error' => 'Company slug required']); exit; }

    if (!$master_db) {
        echo json_encode(['exists' => false, 'error' => 'Master registry not available.']);
        exit;
    }

    $stmt = $master_db->prepare("SELECT business_name, client_db_name, is_active FROM v2_clients WHERE slug = ? LIMIT 1");
    $stmt->bind_param("s", $slug);
    $stmt->execute();
    $row = $stmt->get_result()->fetch_assoc();

    $exists = $row && (int)$row['is_active'] === 1 && !empty($row['client_db_name']);
    echo json_encode(['exists' => (bool)$exists, 'display_name' => $row['business_name'] ?? null]);
    exit;
}

if ($action == 'run_setup') {
    $slug      = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($input['company_slug']    ?? '')));
    $biz_slug  = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($input['business_slug']   ?? $slug)));
    $display   = trim($input['company_name']   ?? '');
    $username  = trim($input['username']       ?? '');
    $password  = $input['password']            ?? '';
    $disp_name = trim($input['display_name']   ?? $username);
    $ship_prov = in_array($input['shipping_provider'] ?? '', ['packie', 'courierit']) ? $input['shipping_provider'] : 'packie';
    $ecom_prov = in_array($input['ecom_provider']     ?? '', ['shopify', 'bigcommerce']) ? $input['ecom_provider'] : 'shopify';

    if (!$slug || !$display || !$username || strlen($password) < 8) {
        echo json_encode(['success' => false, 'error' => 'Missing required fields. Password must be at least 8 characters.']);
        exit;
    }

    if (!$master_db) {
        echo json_encode(['success' => false, 'error' => 'Master registry not available.']);
        exit;
    }

    $mstmt = $master_db->prepare("SELECT id, business_name, client_db_name, is_active, master_password_hash FROM v2_clients WHERE slug = ? LIMIT 1");
    $mstmt->bind_param("s", $biz_slug);
    $mstmt->execute();
    $mrow = $mstmt->get_result()->fetch_assoc();
    if (!$mrow || (int)$mrow['is_active'] !== 1) {
        echo json_encode(['success' => false, 'error' => 'Invalid business slug. Please verify your setup key again.']);
        exit;
    }

    $expected_db = trim((string)($mrow['client_db_name'] ?? ''));
    if ($expected_db === '') {
        $expected_db = generate_client_db_name($slug);
        $um = $master_db->prepare("UPDATE v2_clients SET client_db_name = ? WHERE id = ? AND (client_db_name IS NULL OR client_db_name = '')");
        $um->bind_param("si", $expected_db, $mrow['id']);
        $um->execute();
    }

    if ($expected_db !== $_resolved_client_db) {
        echo json_encode(['success' => false, 'error' => 'Client database routing mismatch. Please retry setup.']);
        exit;
    }

    if (!empty($mrow['master_password_hash'])) {
        echo json_encode(['success' => false, 'error' => 'Setup has already been completed for this client database.']);
        exit;
    }

    $db->begin_transaction();
    try {
        $company_id = 1;
        $pw_hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

        // Store master password hash in afrihair_master.v2_clients
        $upd_pw = $master_db->prepare("UPDATE v2_clients SET master_password_hash = ? WHERE slug = ?");
        $upd_pw->bind_param("ss", $pw_hash, $biz_slug);
        if (!$upd_pw->execute() || $master_db->affected_rows < 1) throw new Exception("Failed to store master credentials");

        // Prefer explicit master first/last inputs when supplied by the client UI
        $mf = trim($input['master_first'] ?? '');
        $ml = trim($input['master_last_initial'] ?? '');

        if ($mf !== '' && $ml !== '') {
            $base_first = preg_replace('/[^\pL\pM\s\-]/u', '', $mf);
            $base_last_initial = strtoupper(substr($ml, 0, 1));
            $base_name = trim($base_first . ' ' . $base_last_initial);
        } else {
            // Derive: first token of display name + initial from last token if present, otherwise from username
            $parts = preg_split('/\s+/', $disp_name);
            $base_first = $parts[0] ?? $username;
            if (count($parts) > 1) {
                $base_last_initial = strtoupper(substr($parts[count($parts) - 1], 0, 1));
            } else {
                $base_last_initial = strtoupper(substr($username, 0, 1));
            }
            $base_name = trim($base_first . ' ' . $base_last_initial);
        }

        // Try to reserve a human-friendly unique name. Strategy:
        // 1) try BaseName (e.g. "Daniel S")
        // 2) try BaseName + ' (' + first2(username) + ')'
        // 3) append numeric suffixes until unique
        $chk_name_stmt = $db->prepare("SELECT id FROM v2_staff_members WHERE name = ?");
        $final_name = $base_name;

        $chk_name_stmt->bind_param("s", $final_name);
        $chk_name_stmt->execute();
        $r = $chk_name_stmt->get_result()->fetch_assoc();

        if ($r) {
            $short = preg_replace('/[^a-z0-9]/i', '', $username);
            $short = substr($short, 0, 2) ?: substr(md5(uniqid()), 0, 2);
            $candidate = $base_name . ' (' . $short . ')';
            $final_name = $candidate;
            $i = 1;
            while (true) {
                $chk_name_stmt->bind_param("s", $final_name);
                $chk_name_stmt->execute();
                $r2 = $chk_name_stmt->get_result()->fetch_assoc();
                if (!$r2) break;
                $i++;
                $final_name = $candidate . ' ' . $i;
                if ($i > 50) break; // safety
            }
        }
        $chk_name_stmt->close();

        $s2 = $db->prepare("INSERT INTO v2_staff_members (company_id, username, name, role, can_pick, can_pack, can_print, is_active) VALUES (?, ?, ?, 'master', 1, 1, 1, 1)");
        $s2->bind_param("iss", $company_id, $username, $final_name);
        if (!$s2->execute()) throw new Exception("Master account creation failed");

        $company_pm = new ProviderManager($db, $company_id);

        // Shipping provider config — written flat into v2_settings table
        $ship_base = ($ship_prov === 'packie') ? 'https://www.packie.co.nz/api' : 'https://api.courierit.co.nz';
        $ship_rows = [
            ['shipping', $ship_prov, 'base_url',             $ship_base],
            ['shipping', $ship_prov, 'username',             $input['shipping_username'] ?? ''],
            ['shipping', $ship_prov, 'api_key',              $input['shipping_api_key']  ?? ''],
            ['shipping', $ship_prov, 'bearer_token',         ''],
            ['shipping', $ship_prov, 'sender_contact_name',  $input['sender_name']       ?? ''],
            ['shipping', $ship_prov, 'sender_company',       $display],
            ['shipping', $ship_prov, 'sender_street',        $input['sender_street']     ?? ''],
            ['shipping', $ship_prov, 'sender_city',          $input['sender_city']       ?? ''],
            ['shipping', $ship_prov, 'sender_postcode',      $input['sender_postcode']   ?? ''],
            ['shipping', $ship_prov, 'sender_country',       'NZ'],
        ];
        foreach ($ship_rows as [$cat, $slug, $key, $val]) {
            $company_pm->saveSetting($cat, $slug, $key, $val);
        }

        // Ecommerce provider config
        $ecom_rows = [
            ['ecommerce', $ecom_prov, 'store_url',   trim($input['ecom_store_url'] ?? '')],
            ['ecommerce', $ecom_prov, 'api_key',     $input['ecom_api_key']        ?? ''],
            ['ecommerce', $ecom_prov, 'api_version', '2024-01'],
        ];
        foreach ($ecom_rows as [$cat, $slug, $key, $val]) {
            $company_pm->saveSetting($cat, $slug, $key, $val);
        }

        // Record which providers are active
        $company_pm->saveSetting('general', 'app', 'active_shipping_provider', $ship_prov);
        $company_pm->saveSetting('general', 'app', 'active_ecom_provider',     $ecom_prov);

        $db->commit();
        echo json_encode(['success' => true, 'message' => "Setup complete for {$display}"]);
    } catch (Exception $e) {
        $db->rollback();
        echo json_encode(['success' => false, 'error' => $e->getMessage()]);
    }
    exit;
}


if ($action == 'login') {
    $company_slug = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($input['company'] ?? '')));
    $password     = $input['password'] ?? '';
    $role_req     = ($input['role'] ?? 'user') === 'admin' ? 'master' : 'secondary';
    $remember     = !empty($input['remember']);
    $_rem_days = (int)($_ENV['SESSION_REMEMBER_TTL_DAYS'] ?? 30);
    $_def_hours = (int)($_ENV['SESSION_DEFAULT_TTL_HOURS'] ?? 8);
    $session_ttl_seconds = $remember ? ($_rem_days * 86400) : ($_def_hours * 3600);

    Logger::info('login', 'attempt', ['company' => $company_slug, 'role_req' => $role_req, 'remote_ip' => ($_SERVER['REMOTE_ADDR'] ?? 'cli')]);

    if (!$company_slug || !$password) {
        Logger::warn('login', 'error_missing_fields', ['company' => $company_slug]);
        echo json_encode(['success' => false, 'error' => 'Company and password required']);
        exit;
    }
    if (!$master_db) {
        Logger::warn('login', 'error_master_db_missing', ['company' => $company_slug]);
        echo json_encode(['success' => false, 'error' => 'Master registry not available']);
        exit;
    }

    // Rate limiting — block after too many failed attempts from the same IP
    $_rl_ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    $_rl_max = (int)($_ENV['RATE_LIMIT_MAX_ATTEMPTS'] ?? 5);
    $_rl_window = (int)($_ENV['RATE_LIMIT_WINDOW_SECONDS'] ?? 300);

    $_rl_stmt = $master_db->prepare(
        "SELECT COUNT(*) AS cnt FROM v2_login_attempts WHERE ip_address = ? AND attempted_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? SECOND)"
    );
    $_rl_stmt->bind_param('si', $_rl_ip, $_rl_window);
    $_rl_stmt->execute();
    $_rl_count = (int)$_rl_stmt->get_result()->fetch_assoc()['cnt'];

    if ($_rl_count >= $_rl_max) {
        http_response_code(429);
        Logger::warn('login', 'rate_limited', ['ip' => $_rl_ip, 'attempts' => $_rl_count]);
        echo json_encode(['success' => false, 'error' => 'Too many login attempts. Please wait a few minutes.']);
        exit;
    }

    // Record this login attempt
    $_rl_ins = $master_db->prepare("INSERT INTO v2_login_attempts (ip_address, company_slug) VALUES (?, ?)");
    $_rl_ins->bind_param('ss', $_rl_ip, $company_slug);
    $_rl_ins->execute();

    // Opportunistic cleanup of old attempts (1% chance per request)
    if (mt_rand(1, 100) === 1) {
        $master_db->query("DELETE FROM v2_login_attempts WHERE attempted_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)");
    }

    // Resolve client DB and credentials from master registry
    $m_stmt = $master_db->prepare("SELECT client_db_name, is_active, master_password_hash, secondary_password_hash FROM v2_clients WHERE slug = ? LIMIT 1");
    $m_stmt->bind_param("s", $company_slug);
    $m_stmt->execute();
    $m_row = $m_stmt->get_result()->fetch_assoc();
    if (!$m_row || (int)$m_row['is_active'] !== 1 || empty($m_row['client_db_name'])) {
        Logger::warn('login', 'error_client_not_resolved', ['slug' => $company_slug, 'm_row' => $m_row]);
        echo json_encode(['success' => false, 'error' => 'Invalid credentials']);
        exit;
    }

    // Connect to client DB (reuse existing routing logic earlier in file if available)
    $client_db_name = $m_row['client_db_name'];
    $client_db = new mysqli($db_host, $db_user, $db_pass, $client_db_name);
    if ($client_db->connect_error) {
        Logger::error('login', 'error_client_db_connect', ['db' => $client_db_name, 'error' => $client_db->connect_error]);
        echo json_encode(['success' => false, 'error' => 'Client DB unavailable']);
        exit;
    }
    $client_db->query("SET time_zone = '+00:00'");

    // Ensure v2_auth_sessions table and all required columns exist before inserting.
    // The main bootstrap migration only runs for non-login actions, so we must
    // apply it here too to prevent silent INSERT failures on pre-migration DBs.
    $client_db->query("
        CREATE TABLE IF NOT EXISTS `v2_auth_sessions` (
            `token` char(64) NOT NULL,
            `staff_id` int NOT NULL,
            `company_id` int NOT NULL,
            `device_name` varchar(100) DEFAULT NULL,
            `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
            `expires_at` timestamp NOT NULL,
            PRIMARY KEY (`token`),
            KEY `idx_staff` (`staff_id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    ");
    $_lc_q    = $client_db->query("SHOW COLUMNS FROM v2_auth_sessions");
    $_lc_cols = [];
    if ($_lc_q) while ($_lc_c = $_lc_q->fetch_assoc()) $_lc_cols[] = $_lc_c['Field'];
    if (!in_array('company_id',  $_lc_cols)) $client_db->query("ALTER TABLE v2_auth_sessions ADD COLUMN `company_id` int NOT NULL DEFAULT 1 AFTER `staff_id`");
    if (!in_array('device_name', $_lc_cols)) $client_db->query("ALTER TABLE v2_auth_sessions ADD COLUMN `device_name` varchar(100) DEFAULT NULL");
    if (!in_array('expires_at',  $_lc_cols)) $client_db->query("ALTER TABLE v2_auth_sessions ADD COLUMN `expires_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP");
    unset($_lc_q, $_lc_cols, $_lc_c);

    $company_id = 1; // company is always 1 per-client-DB; no v2_companies lookup needed

    if ($role_req === 'master') {
        // Verify master password against hash stored in afrihair_master.v2_clients
        if (empty($m_row['master_password_hash']) || !password_verify($password, $m_row['master_password_hash'])) {
            Logger::warn('login', 'master_password_mismatch', ['company' => $company_slug]);
            echo json_encode(['success' => false, 'error' => 'Invalid credentials']);
            exit;
        }
        // Load staff row from client DB for name and permissions
        $u_stmt = $client_db->prepare("SELECT id, name, role, can_pick, can_pack, can_print FROM v2_staff_members WHERE role = 'master' AND is_active = 1 LIMIT 1");
        $u_stmt->execute();
        $user = $u_stmt->get_result()->fetch_assoc();
        if (!$user) { Logger::warn('login', 'master_no_staff_row', ['company' => $company_slug]); echo json_encode(['success'=>false,'error'=>'Invalid credentials']); exit; }
        $token = bin2hex(random_bytes(32));
        $expires_at = gmdate('Y-m-d H:i:s', time() + $session_ttl_seconds);
        $t_stmt = $client_db->prepare("INSERT INTO v2_auth_sessions (token, staff_id, company_id, device_name, expires_at) VALUES (?, ?, ?, ?, ?)");
        if (!$t_stmt) {
            Logger::error('login', 'v2_auth_sessions prepare failed (master)', ['error' => $client_db->error]);
            echo json_encode(['success' => false, 'error' => 'Session creation failed. Please try again.']);
            exit;
        }
        $device_name = $input['device_name'] ?? 'device';
        $user_id = (int)($user['id'] ?? 0);
        $t_stmt->bind_param("siiss", $token, $user_id, $company_id, $device_name, $expires_at);
        if (!$t_stmt->execute()) {
            Logger::error('login', 'v2_auth_sessions INSERT failed (master)', ['error' => $t_stmt->error]);
            echo json_encode(['success' => false, 'error' => 'Session creation failed. Please try again.']);
            exit;
        }
        Logger::info('login', 'master_success', ['company' => $company_slug, 'user_id' => $user_id, 'token_prefix' => substr($token, 0, 8)]);
        echo json_encode(['success'=>true,'token'=>$token,'expires_at'=>$expires_at,'user'=>['id'=>(int)$user['id'],'name'=>$user['name'],'role'=>$user['role'],'can_pick'=>(bool)$user['can_pick'],'can_pack'=>(bool)$user['can_pack'],'can_print'=>(bool)$user['can_print']]]);
        exit;
    } else {
        // Secondary (shared) password flow
        // Verify secondary password against hash stored in afrihair_master.v2_clients
        if (empty($m_row['secondary_password_hash']) || !password_verify($password, $m_row['secondary_password_hash'])) {
            Logger::warn('login', 'secondary_password_mismatch', ['company' => $company_slug]);
            echo json_encode(['success' => false, 'error' => 'Invalid credentials']);
            exit;
        }

        // Find or create a secondary staff row to attach session to.
        $ss = $client_db->prepare("SELECT id, name, role, can_pick, can_pack, can_print FROM v2_staff_members WHERE role = 'secondary' AND is_active = 1 LIMIT 1");
        $ss->execute();
        $sec = $ss->get_result()->fetch_assoc();
        if (!$sec) {
            $ins = $client_db->prepare("INSERT INTO v2_staff_members (company_id, name, role, is_active) VALUES (?, 'Pack Station User', 'secondary', 1)");
            $ins->bind_param("i", $company_id);
            $ins->execute();
            $sec_id = (int)$client_db->insert_id;
            $sec = ['id'=>$sec_id,'name'=>'Pack Station User','role'=>'secondary','can_pick'=>1,'can_pack'=>1,'can_print'=>0];
        }

        $token = bin2hex(random_bytes(32));
        $expires_at = gmdate('Y-m-d H:i:s', time() + $session_ttl_seconds);
        $t_stmt = $client_db->prepare("INSERT INTO v2_auth_sessions (token, staff_id, company_id, device_name, expires_at) VALUES (?, ?, ?, ?, ?)");
        if (!$t_stmt) {
            Logger::error('login', 'v2_auth_sessions prepare failed (secondary)', ['error' => $client_db->error]);
            echo json_encode(['success' => false, 'error' => 'Session creation failed. Please try again.']);
            exit;
        }
        $device_name = $input['device_name'] ?? 'device';
        $sec_id = (int)($sec['id'] ?? 0);
        $t_stmt->bind_param("siiss", $token, $sec_id, $company_id, $device_name, $expires_at);
        if (!$t_stmt->execute()) {
            Logger::error('login', 'v2_auth_sessions INSERT failed (secondary)', ['error' => $t_stmt->error]);
            echo json_encode(['success' => false, 'error' => 'Session creation failed. Please try again.']);
            exit;
        }
        Logger::info('login', 'secondary_success', ['company' => $company_slug, 'sec_id' => $sec_id, 'token_prefix' => substr($token, 0, 8)]);
        echo json_encode(['success'=>true,'token'=>$token,'expires_at'=>$expires_at,'user'=>[
            'id'=>(int)$sec['id'],
            'name'=>$sec['name'],
            'role'=>$sec['role'],
            'can_pick'=>(bool)($sec['can_pick'] ?? 0),
            'can_pack'=>(bool)($sec['can_pack'] ?? 0),
            'can_print'=>(bool)($sec['can_print'] ?? 0),
        ]]);
        exit;
    }
}

if ($action === 'set_secondary_password') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }

    $password = trim((string)($input['password'] ?? ''));
    if (strlen($password) < 4) {
        echo json_encode(['success' => false, 'error' => 'Secondary password must be at least 4 characters.']);
        exit;
    }

    $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
    $sp_stmt = $master_db->prepare("UPDATE v2_clients SET secondary_password_hash = ? WHERE client_db_name = ? LIMIT 1");
    $sp_stmt->bind_param("ss", $hash, $_resolved_client_db);
    $sp_stmt->execute();

    Logger::warn('auth.password_change', 'Secondary password changed', ['by' => $caller['name'] ?? 'unknown']);

    echo json_encode(['success' => true]);
    exit;
}

if ($action == 'logout') {
    $req_headers = function_exists('getallheaders') ? getallheaders() : [];
    $token = null;
    foreach ($req_headers as $k => $v) {
        if (strtolower($k) === 'x-auth-token') { $token = $v; break; }
    }
    if (!$token) $token = $input['token'] ?? null;
    if ($token) {
        $token = preg_replace('/[^a-f0-9]/', '', strtolower(trim($token)));
        $stmt = $db->prepare("DELETE FROM v2_auth_sessions WHERE token = ?");
        $stmt->bind_param("s", $token);
        $stmt->execute();
    }
    echo json_encode(['success' => true]);
    exit;
}

if ($action == 'create_staff') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }
    $company_id = (int)$caller['company_id'];
    $username   = trim($input['username']     ?? '');
    $disp_name  = trim($input['display_name'] ?? $username);
    $password   = $input['password']          ?? '';
    $role       = in_array($input['role'] ?? '', ['master', 'secondary']) ? $input['role'] : 'secondary';
    $can_pick   = isset($input['can_pick'])  ? (int)(bool)$input['can_pick']  : 1;
    $can_pack   = isset($input['can_pack'])  ? (int)(bool)$input['can_pack']  : 1;
    $can_print  = isset($input['can_print']) ? (int)(bool)$input['can_print'] : 0;

    if (!$username || strlen($password) < 8) {
        echo json_encode(['success' => false, 'error' => 'Username required. Password must be at least 8 characters.']);
        exit;
    }

    $chk = $db->prepare("SELECT id FROM v2_staff_members WHERE company_id = ? AND username = ?");
    $chk->bind_param("is", $company_id, $username);
    $chk->execute();
    if ($chk->get_result()->fetch_assoc()) {
        echo json_encode(['success' => false, 'error' => 'Username already taken']);
        exit;
    }

    $pw_hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
    $stmt = $db->prepare("INSERT INTO v2_staff_members (company_id, username, password_hash, name, role, can_pick, can_pack, can_print, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)");
    $stmt->bind_param("issssiii", $company_id, $username, $pw_hash, $disp_name, $role, $can_pick, $can_pack, $can_print);
    $stmt->execute();
    echo json_encode(['success' => true, 'id' => (int)$db->insert_id]);
    exit;
}

if ($action == 'list_staff_full') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }
    $company_id = (int)$caller['company_id'];
    $stmt = $db->prepare("SELECT id, username, name, role, can_pick, can_pack, can_print, is_active, last_login FROM v2_staff_members WHERE company_id = ? ORDER BY name ASC");
    $stmt->bind_param("i", $company_id);
    $stmt->execute();
    $rows = $stmt->get_result();
    $staff = [];
    while ($row = $rows->fetch_assoc()) {
        $staff[] = [
            'id'         => (int)$row['id'],
            'username'   => $row['username'],
            'name'       => $row['name'],
            'role'       => $row['role'],
            'can_pick'   => (bool)$row['can_pick'],
            'can_pack'   => (bool)$row['can_pack'],
            'can_print'  => (bool)$row['can_print'],
            'is_active'  => (bool)$row['is_active'],
            'last_login' => $row['last_login'],
        ];
    }
    echo json_encode(['success' => true, 'staff' => $staff]);
    exit;
}

if ($action == 'update_staff_perms') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }
    $company_id = (int)$caller['company_id'];
    $target_id  = (int)($input['id'] ?? 0);
    if ($target_id <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid id']); exit; }

    $sets = []; $types = ''; $params = [];
    if (isset($input['can_pick']))  { $sets[] = '`can_pick` = ?';  $types .= 'i'; $params[] = (int)(bool)$input['can_pick']; }
    if (isset($input['can_pack']))  { $sets[] = '`can_pack` = ?';  $types .= 'i'; $params[] = (int)(bool)$input['can_pack']; }
    if (isset($input['can_print'])) { $sets[] = '`can_print` = ?'; $types .= 'i'; $params[] = (int)(bool)$input['can_print']; }
    if (isset($input['is_active'])) { $sets[] = '`is_active` = ?'; $types .= 'i'; $params[] = (int)(bool)$input['is_active']; }
    if (isset($input['role']) && in_array($input['role'], ['master', 'secondary'])) {
        $sets[] = '`role` = ?'; $types .= 's'; $params[] = $input['role'];
    }
    if (empty($sets)) { echo json_encode(['success' => false, 'error' => 'Nothing to update']); exit; }

    $params[] = $target_id;
    $params[] = $company_id;
    $types   .= 'ii';

    $stmt = $db->prepare("UPDATE v2_staff_members SET " . implode(', ', $sets) . " WHERE id = ? AND company_id = ?");
    $stmt->bind_param($types, ...$params);
    $stmt->execute();
    Logger::info('staff.permission_change', 'Staff permissions updated', ['staff_id' => $id, 'changes' => $input, 'by' => $caller['name'] ?? 'unknown']);
    echo json_encode(['success' => true]);
    exit;
}

if ($action == 'get_provider_config') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }
    $company_id = (int)$caller['company_id'];
    // api_key and bearer_token are intentionally excluded from this response
    $excluded_keys = ['api_key', 'api_secret', 'bearer_token', 'secondary_shared_password'];
    $stmt = $db->prepare("SELECT category, provider_slug, `key`, `value` FROM v2_settings WHERE company_id = ? ORDER BY category, provider_slug");
    $stmt->bind_param('i', $company_id);
    $stmt->execute();
    $res = $stmt->get_result();
    $grouped = [];
    while ($row = $res->fetch_assoc()) {
        $k = $row['category'] . '/' . $row['provider_slug'];
        if (in_array($row['key'], $excluded_keys, true)) {
            $grouped[$k]['__has_' . $row['key']] = true;
            continue;
        }
        $grouped[$k][$row['key']] = $row['value'];
    }
    echo json_encode(['success' => true, 'configs' => $grouped]);
    exit;
}

if ($action == 'force_update_courier') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }

    $provider_slug = strtolower(trim($input['provider_slug'] ?? $pm->getShipSlug()));
    $active_slug = $pm->getShipSlug();

    if ($provider_slug === '') $provider_slug = $active_slug;
    if ($provider_slug !== $active_slug) {
        echo json_encode(['success' => false, 'error' => 'Only the active courier can be force-updated right now.']);
        exit;
    }

    if ($provider_slug !== 'packie') {
        echo json_encode(['success' => false, 'error' => 'Force update is not implemented for this courier yet.']);
        exit;
    }

    if (!function_exists('sync_packie_orders_and_stocks')) {
        echo json_encode(['success' => false, 'error' => 'Packie sync function not available']);
        exit;
    }

    // Execute sync in-process so writes stay on the currently routed client DB.
    $parsed = sync_packie_orders_and_stocks($db);
    if (!is_array($parsed) || empty($parsed['success'])) {
        echo json_encode(['success' => false, 'error' => $parsed['message'] ?? 'Force update failed', 'result' => $parsed]);
        exit;
    }

    echo json_encode(['success' => true, 'provider' => $provider_slug, 'db' => $_resolved_client_db, 'result' => $parsed]);
    exit;
}

if ($action == 'save_provider_config') {
    $caller = get_current_user_from_request($db);
    if (!$caller || $caller['role'] !== 'master') {
        http_response_code(403);
        echo json_encode(['success' => false, 'error' => 'Master access required']);
        exit;
    }

    $category = strtolower(trim($input['category'] ?? ''));
    $provider_slug = strtolower(trim($input['provider_slug'] ?? ''));
    $fields = $input['fields'] ?? null;

    if (!in_array($category, ['shipping', 'ecommerce'], true) || !is_array($fields)) {
        echo json_encode(['success' => false, 'error' => 'Invalid payload']);
        exit;
    }

    $company_pm = new ProviderManager($db, (int)$caller['company_id']);

    if ($category === 'ecommerce' && in_array($provider_slug, ['none', 'disabled', 'off'], true)) {
        $company_pm->saveSetting('general', 'app', 'active_ecom_provider', '');
        echo json_encode(['success' => true, 'ecommerce_enabled' => false]);
        exit;
    }

    if ($provider_slug === '') {
        echo json_encode(['success' => false, 'error' => 'Invalid provider_slug']);
        exit;
    }

    if ($category === 'shipping' && $provider_slug === 'nzpost' && $company_pm->getEcomSlug() === '') {
        echo json_encode(['success' => false, 'error' => 'NZPost requires an ecommerce provider. Enable ecommerce first.']);
        exit;
    }

    foreach ($fields as $key => $value) {
        $safe_key = preg_replace('/[^a-z0-9_\-\.]/i', '', (string)$key);
        if ($safe_key === '') continue;
        if (is_array($value) || is_object($value)) {
            $value = json_encode($value);
        }
        $company_pm->saveSetting($category, $provider_slug, $safe_key, (string)($value ?? ''));
    }

    if ($category === 'shipping') {
        $company_pm->saveSetting('general', 'app', 'active_shipping_provider', $provider_slug);
    } else {
        $company_pm->saveSetting('general', 'app', 'active_ecom_provider', $provider_slug);
    }

    Logger::warn('config.save', 'Provider config saved', ['category' => $category, 'provider_slug' => $provider_slug, 'by' => $caller['name'] ?? 'unknown']);

    echo json_encode(['success' => true]);
    exit;
}

// ====================================================================
// MASTER DB ACTIONS  (afrihair_master)
// ====================================================================

/**
 * verify_client
 * POST { business_slug, client_key }
 * Checks the slug + key against afrihair_master.v2_clients.
 * Returns { valid, business_name, client_db_name } or error.
 * Used by the First Run onboarding wizard to gate setup.
 */
if ($action === 'verify_client') {
    if (!$master_db) {
        echo json_encode(['valid' => false, 'error' => 'Master registry not initialised. Run setup_master.php first.']);
        exit;
    }
    $slug = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($input['business_slug'] ?? '')));
    $key  = trim($input['client_key'] ?? '');
    if (!$slug || !$key) {
        echo json_encode(['valid' => false, 'error' => 'Business slug and setup key are required.']);
        exit;
    }
    $stmt = $master_db->prepare("
        SELECT id, business_name, client_key_hash, client_db_name, is_active, setup_completed_at
        FROM v2_clients WHERE slug = ?
    ");
    $stmt->bind_param('s', $slug);
    $stmt->execute();
    $client = $stmt->get_result()->fetch_assoc();
    if (!$client || !$client['is_active']) {
        echo json_encode(['valid' => false, 'error' => 'Business not found. Contact your system administrator.']);
        exit;
    }
    if (!password_verify($key, $client['client_key_hash'])) {
        echo json_encode(['valid' => false, 'error' => 'Invalid setup key.']);
        exit;
    }
    // Mark setup_completed_at on first successful verification (idempotent)
    if (!$client['setup_completed_at']) {
        $upd = $master_db->prepare("UPDATE v2_clients SET setup_completed_at = NOW() WHERE id = ?");
        $upd->bind_param('i', $client['id']);
        $upd->execute();
    }
    echo json_encode([
        'valid'         => true,
        'business_name' => $client['business_name'],
        'client_db_name'=> $client['client_db_name'],
    ]);
    exit;
}

/**
 * list_shipping_providers
 * GET  (no auth required — public provider catalogue)
 * Returns all active shipping providers with their setup wizard field definitions.
 */
if ($action === 'list_shipping_providers') {
    if (!$master_db) {
        echo json_encode(['success' => false, 'error' => 'Master registry not available.']);
        exit;
    }
    $res = $master_db->query("
        SELECT slug, display_name, auth_type, required_fields, notes
        FROM v2_shipping_providers WHERE is_active = 1 ORDER BY display_name
    ");
    $providers = [];
    while ($row = $res->fetch_assoc()) {
        $providers[] = [
            'slug'             => $row['slug'],
            'display_name'     => $row['display_name'],
            'auth_type'        => $row['auth_type'],
            'required_fields'  => json_decode($row['required_fields'], true),
            'notes'            => $row['notes'],
        ];
    }
    echo json_encode(['success' => true, 'providers' => $providers]);
    exit;
}

/**
 * list_cms_providers
 * GET  (no auth required — public provider catalogue)
 * Returns all active CMS providers with their setup wizard field definitions.
 */
if ($action === 'list_cms_providers') {
    if (!$master_db) {
        echo json_encode(['success' => false, 'error' => 'Master registry not available.']);
        exit;
    }
    $res = $master_db->query("
        SELECT slug, display_name, api_base_pattern, required_fields, notes
        FROM v2_cms_providers WHERE is_active = 1 ORDER BY display_name
    ");
    $providers = [];
    while ($row = $res->fetch_assoc()) {
        $providers[] = [
            'slug'            => $row['slug'],
            'display_name'    => $row['display_name'],
            'required_fields' => json_decode($row['required_fields'], true),
            'notes'           => $row['notes'],
        ];
    }
    echo json_encode(['success' => true, 'providers' => $providers]);
    exit;
}

// ── get_app_settings — fetch app settings from v2_settings ──────────────────
if ($action == 'get_app_settings') {
    $caller = get_current_user_from_request($db);
    if (!$caller) { http_response_code(401); echo json_encode(['success' => false, 'error' => 'Unauthorized']); exit; }
    $company_id = (int)$caller['company_id'];
    $stmt = $db->prepare("SELECT `key`, `value` FROM v2_settings WHERE company_id = ? AND category = 'general' AND provider_slug = 'app_settings'");
    $stmt->bind_param('i', $company_id);
    $stmt->execute();
    $res = $stmt->get_result();
    $settings = [];
    while ($row = $res->fetch_assoc()) {
        $settings[$row['key']] = $row['value'];
    }
    echo json_encode(['success' => true, 'settings' => $settings]);
    exit;
}

// ── save_app_settings — persist app settings to v2_settings ─────────────────
if ($action == 'save_app_settings') {
    $caller = get_current_user_from_request($db);
    if (!$caller) { http_response_code(401); echo json_encode(['success' => false, 'error' => 'Unauthorized']); exit; }
    $settings = $input['settings'] ?? [];
    if (!is_array($settings)) { echo json_encode(['success' => false, 'error' => 'Invalid settings']); exit; }
    $company_id = (int)$caller['company_id'];
    foreach ($settings as $key => $value) {
        $safe_key = preg_replace('/[^a-zA-Z0-9_]/', '', $key);
        if ($safe_key === '') continue;
        $pm->saveSetting('general', 'app_settings', $safe_key, (string)$value);
    }
    Logger::info('settings.save', 'App settings saved', ['by' => $caller['name'] ?? 'unknown', 'keys' => array_keys($settings)]);
    echo json_encode(['success' => true]);
    exit;
}

echo json_encode(["error" => "Unknown action"]);
?>
