<?php
/**
 * WHMCS -> Blesta Migration Scanner
 * ---------------------------------
 * Read-only. Collects metadata (versions, counts, modules, compatibility flags) from a WHMCS
 * installation and, optionally, compares it against an existing Blesta installation to find
 * duplicates / changed records. The resulting JSON contains NO personal data: only counts,
 * module names and internal record IDs.
 *
 * Usage (CLI):
 *   php w2b-scan.php --whmcs-path=/var/www/whmcs [--blesta-path=/var/www/blesta] --out=scan.json
 *   php w2b-scan.php --whmcs-dsn="mysql:host=localhost;dbname=whmcs" --whmcs-user=u --whmcs-pass=p \
 *                    [--blesta-dsn=... --blesta-user=... --blesta-pass=...] --out=scan.json
 *
 * Browser mode (no SSH needed): copy this file into the WHMCS folder (next to configuration.php) and open
 *   https://your-whmcs-domain/w2b-scan.php?key=<your key>
 * The key is shown on the migration website. Delete the file afterwards.
 *
 * Requires PHP 7.2+ with PDO (pdo_mysql). Compatible with PHP 8.x.
 * Can also be included as a library (define W2B_SCANNER_LIB before including).
 */

if (!defined('W2B_SCANNER_VERSION')) {
    define('W2B_SCANNER_VERSION', '1.1.0');
}
// Personal access key for browser mode. Inserted automatically when the file is downloaded from the migration website.
if (!defined('W2B_WEB_KEY')) {
    define('W2B_WEB_KEY', 'e1e2958bcaa654c0bb4605e4');
}

/* ===================================================================================== */
/*  Helpers                                                                               */
/* ===================================================================================== */

class W2B_Util
{
    public static function norm($s)
    {
        $s = trim((string)$s);
        $s = function_exists('mb_strtolower') ? mb_strtolower($s, 'UTF-8') : strtolower($s);
        return preg_replace('/\s+/u', ' ', $s);
    }

    public static function normEmail($s)
    {
        return strtolower(trim((string)$s));
    }

    /** Returns Y-m-d or null. */
    public static function day($date)
    {
        $date = trim((string)$date);
        if ($date === '' || strpos($date, '0000-00-00') === 0) {
            return null;
        }
        $ts = strtotime($date);
        return $ts ? date('Y-m-d', $ts) : null;
    }

    /** True if two dates are within $tolerance days (time zone differences between systems). */
    public static function sameDay($a, $b, $tolerance = 1)
    {
        $a = self::day($a);
        $b = self::day($b);
        if ($a === null || $b === null) {
            return $a === $b;
        }
        return abs((strtotime($a) - strtotime($b)) / 86400) <= $tolerance;
    }

    public static function money($v)
    {
        return round((float)$v, 2);
    }

    public static function moneyEq($a, $b)
    {
        return abs(self::money($a) - self::money($b)) < 0.01;
    }

    public static function digits($s)
    {
        return preg_replace('/\D+/', '', (string)$s);
    }
}

class W2B_Db
{
    private $pdo;
    private $tableCache = array();
    private $colCache = array();

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    }

    public static function connect($dsn, $user = null, $pass = null)
    {
        $opts = array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION);
        if (strpos($dsn, 'mysql:') === 0 && strpos($dsn, 'charset=') === false) {
            $dsn .= ';charset=utf8mb4';
        }
        return new self(new PDO($dsn, $user, $pass, $opts));
    }

    public function pdo()
    {
        return $this->pdo;
    }

    public function driver()
    {
        return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
    }

    public function hasTable($table)
    {
        if (!array_key_exists($table, $this->tableCache)) {
            try {
                $this->pdo->query('SELECT 1 FROM ' . $table . ' LIMIT 1');
                $this->tableCache[$table] = true;
            } catch (Exception $e) {
                $this->tableCache[$table] = false;
            }
        }
        return $this->tableCache[$table];
    }

    public function columns($table)
    {
        if (!isset($this->colCache[$table])) {
            $cols = array();
            if ($this->hasTable($table)) {
                $st = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 0');
                for ($i = 0; $i < $st->columnCount(); $i++) {
                    $m = $st->getColumnMeta($i);
                    if ($m && isset($m['name'])) {
                        $cols[] = $m['name'];
                    }
                }
            }
            $this->colCache[$table] = $cols;
        }
        return $this->colCache[$table];
    }

    public function hasColumn($table, $col)
    {
        return in_array($col, $this->columns($table), true);
    }

    public function all($sql, array $params = array())
    {
        $st = $this->pdo->prepare($sql);
        $st->execute($params);
        return $st->fetchAll(PDO::FETCH_ASSOC);
    }

    public function one($sql, array $params = array())
    {
        $rows = $this->all($sql, $params);
        return $rows ? $rows[0] : null;
    }

    public function value($sql, array $params = array())
    {
        $row = $this->one($sql, $params);
        return $row ? reset($row) : null;
    }

    public function count($table, $where = '')
    {
        if (!$this->hasTable($table)) {
            return null;
        }
        return (int)$this->value('SELECT COUNT(*) FROM ' . $table . ($where ? ' WHERE ' . $where : ''));
    }
}

/* ===================================================================================== */
/*  WHMCS reader                                                                          */
/* ===================================================================================== */

class W2B_WhmcsReader
{
    /** @var W2B_Db */
    private $db;
    private $path;

    public function __construct(W2B_Db $db, $path = null)
    {
        $this->db = $db;
        $this->path = $path ? rtrim($path, '/') : null;
    }

    public function db()
    {
        return $this->db;
    }

    public function version()
    {
        if (!$this->db->hasTable('tblconfiguration')) {
            return null;
        }
        return $this->db->value("SELECT value FROM tblconfiguration WHERE setting = 'Version'");
    }

    public function counts()
    {
        $d = $this->db;
        return array(
            'clients' => $d->count('tblclients'),
            'clients_active' => $d->count('tblclients', "status = 'Active'"),
            'contacts' => $d->count('tblcontacts'),
            'users' => $d->count('tblusers'),
            'product_groups' => $d->count('tblproductgroups'),
            'products' => $d->count('tblproducts'),
            'services' => $d->count('tblhosting'),
            'services_active' => $d->count('tblhosting', "domainstatus = 'Active'"),
            'domains' => $d->count('tbldomains'),
            'domains_active' => $d->count('tbldomains', "status = 'Active'"),
            'invoices' => $d->count('tblinvoices'),
            'invoices_unpaid' => $d->count('tblinvoices', "status = 'Unpaid'"),
            'transactions' => $d->count('tblaccounts'),
            'refunds' => $d->count('tblaccounts', 'amountout > 0'),
            'tickets' => $d->count('tbltickets'),
            'ticket_replies' => $d->count('tblticketreplies'),
            'ticket_departments' => $d->count('tblticketdepartments'),
            'admins' => $d->count('tbladmins'),
            'currencies' => $d->count('tblcurrencies'),
            'tax_rules' => $d->count('tbltax'),
            'custom_fields' => $d->count('tblcustomfields'),
            'config_options' => $d->count('tblproductconfigoptions'),
            'email_templates' => $d->count('tblemailtemplates'),
            'promotions' => $d->count('tblpromotions'),
            'quotes' => $d->count('tblquotes'),
            'affiliates' => $d->count('tblaffiliates'),
            'knowledgebase' => $d->count('tblknowledgebase'),
        );
    }

    public function currencies()
    {
        if (!$this->db->hasTable('tblcurrencies')) {
            return array();
        }
        $out = array();
        foreach ($this->db->all('SELECT id, code FROM tblcurrencies') as $r) {
            $out[(int)$r['id']] = strtoupper($r['code']);
        }
        return $out;
    }

    public function defaultCurrency()
    {
        if (!$this->db->hasTable('tblcurrencies')) {
            return null;
        }
        // The column is named `default` (reserved word), so read all rows and check in PHP.
        $rows = $this->db->all('SELECT * FROM tblcurrencies');
        foreach ($rows as $r) {
            if (!empty($r['default'])) {
                return strtoupper($r['code']);
            }
        }
        return $rows ? strtoupper($rows[0]['code']) : null;
    }

    /** MySQL only: table collations. */
    public function collations()
    {
        if ($this->db->driver() !== 'mysql') {
            return array('checked' => false, 'distinct' => array(), 'mixed' => false);
        }
        try {
            $rows = $this->db->all(
                'SELECT TABLE_COLLATION AS c, COUNT(*) AS n FROM information_schema.TABLES '
                . 'WHERE TABLE_SCHEMA = DATABASE() GROUP BY TABLE_COLLATION'
            );
        } catch (Exception $e) {
            return array('checked' => false, 'distinct' => array(), 'mixed' => false);
        }
        $distinct = array();
        foreach ($rows as $r) {
            if ($r['c'] !== null) {
                $distinct[$r['c']] = (int)$r['n'];
            }
        }
        return array(
            'checked' => true,
            'distinct' => $distinct,
            'mixed' => count($distinct) > 1,
            'non_utf8mb4' => count(array_filter(array_keys($distinct), function ($c) {
                return strpos($c, 'utf8mb4') !== 0;
            })) > 0,
        );
    }

    /**
     * Modules in use. Each: type (server|registrar|gateway|addon), key, usage, active.
     */
    public function modules()
    {
        $d = $this->db;
        $mods = array();
        $add = function ($type, $key, $usage, $active, $source) use (&$mods) {
            $key = strtolower(trim((string)$key));
            if ($key === '') {
                return;
            }
            $id = $type . ':' . $key;
            if (!isset($mods[$id])) {
                $mods[$id] = array('type' => $type, 'key' => $key, 'usage' => 0, 'active' => false, 'sources' => array());
            }
            $mods[$id]['usage'] += (int)$usage;
            $mods[$id]['active'] = $mods[$id]['active'] || $active;
            if (!in_array($source, $mods[$id]['sources'], true)) {
                $mods[$id]['sources'][] = $source;
            }
        };

        // Server modules (products + servers)
        if ($d->hasTable('tblproducts') && $d->hasColumn('tblproducts', 'servertype')) {
            $rows = $d->all("SELECT p.servertype AS m, COUNT(h.id) AS n FROM tblproducts p "
                . "LEFT JOIN tblhosting h ON h.packageid = p.id AND h.domainstatus IN ('Active','Suspended','Pending') "
                . "WHERE p.servertype <> '' GROUP BY p.servertype");
            foreach ($rows as $r) {
                $add('server', $r['m'], $r['n'], true, 'products');
            }
        }
        if ($d->hasTable('tblservers')) {
            foreach ($d->all("SELECT type AS m, COUNT(*) AS n FROM tblservers GROUP BY type") as $r) {
                $add('server', $r['m'], 0, true, 'servers');
            }
        }
        // Registrars
        if ($d->hasTable('tblregistrars')) {
            foreach ($d->all('SELECT DISTINCT registrar AS m FROM tblregistrars') as $r) {
                $add('registrar', $r['m'], 0, true, 'config');
            }
        }
        if ($d->hasTable('tbldomains')) {
            foreach ($d->all("SELECT registrar AS m, COUNT(*) AS n FROM tbldomains WHERE status IN ('Active','Pending','Pending Transfer','Grace','Redemption') GROUP BY registrar") as $r) {
                $add('registrar', $r['m'] !== '' ? $r['m'] : 'none', $r['n'], true, 'domains');
            }
        }
        // Gateways
        if ($d->hasTable('tblpaymentgateways')) {
            foreach ($d->all('SELECT DISTINCT gateway AS m FROM tblpaymentgateways') as $r) {
                $usage = $d->hasTable('tblinvoices')
                    ? (int)$d->value('SELECT COUNT(*) FROM tblinvoices WHERE paymentmethod = ?', array($r['m']))
                    : 0;
                $add('gateway', $r['m'], $usage, true, 'config');
            }
        }
        // Addons (active addons store a "version" setting)
        if ($d->hasTable('tbladdonmodules')) {
            foreach ($d->all("SELECT DISTINCT module AS m FROM tbladdonmodules WHERE setting = 'version'") as $r) {
                $add('addon', $r['m'], 0, true, 'config');
            }
        }
        // Filesystem (installed but maybe inactive)
        if ($this->path) {
            $dirs = array('addon' => 'modules/addons', 'server' => 'modules/servers', 'registrar' => 'modules/registrars');
            foreach ($dirs as $type => $rel) {
                foreach ((array)glob($this->path . '/' . $rel . '/*', GLOB_ONLYDIR) as $dir) {
                    $add($type, basename($dir), 0, false, 'files');
                }
            }
            foreach ((array)glob($this->path . '/modules/gateways/*.php') as $f) {
                $add('gateway', basename($f, '.php'), 0, false, 'files');
            }
        }

        // Filter out files-only modules that ship with WHMCS by default and are not active,
        // to keep the report focused on what is actually in use.
        $out = array();
        foreach ($mods as $m) {
            if (!$m['active'] && $m['sources'] === array('files')) {
                continue;
            }
            $out[] = $m;
        }
        usort($out, function ($a, $b) {
            return strcmp($a['type'] . $a['key'], $b['type'] . $b['key']);
        });
        return $out;
    }

    public function hooks()
    {
        if (!$this->path) {
            return null;
        }
        $out = array();
        foreach ((array)glob($this->path . '/includes/hooks/*.php') as $f) {
            $name = basename($f);
            if (in_array($name, array('index.php', 'example.php'), true)) {
                continue;
            }
            $out[] = $name;
        }
        return $out;
    }

    public function customTemplates()
    {
        if (!$this->path) {
            return null;
        }
        $default = array('six', 'twenty-one', 'nexus', 'orderforms', 'kayako', 'portal');
        $out = array();
        foreach ((array)glob($this->path . '/templates/*', GLOB_ONLYDIR) as $dir) {
            $name = basename($dir);
            if (!in_array($name, $default, true)) {
                $out[] = $name;
            }
        }
        return $out;
    }

    public function flags()
    {
        $d = $this->db;
        $flags = array();

        // Active PayPal / gateway subscriptions
        $flags['subscriptions_active'] = ($d->hasTable('tblhosting') && $d->hasColumn('tblhosting', 'subscriptionid'))
            ? (int)$d->value("SELECT COUNT(*) FROM tblhosting WHERE subscriptionid <> '' AND subscriptionid IS NOT NULL AND domainstatus = 'Active'")
            : 0;

        // Stored payment methods
        if ($d->hasTable('tblpaymethods')) {
            $flags['stored_paymethods'] = (int)$d->value('SELECT COUNT(*) FROM tblpaymethods'
                . ($d->hasColumn('tblpaymethods', 'deleted_at') ? ' WHERE deleted_at IS NULL' : ''));
        } elseif ($d->hasColumn('tblclients', 'cardlastfour')) {
            $flags['stored_paymethods'] = (int)$d->value("SELECT COUNT(*) FROM tblclients WHERE cardlastfour <> ''");
        } else {
            $flags['stored_paymethods'] = 0;
        }

        // Password hash types (WHMCS 8 stores logins in tblusers)
        $table = $d->hasTable('tblusers') && $d->hasColumn('tblusers', 'password') ? 'tblusers' : 'tblclients';
        $bcrypt = 0;
        $legacy = 0;
        if ($d->hasColumn($table, 'password')) {
            foreach ($d->all('SELECT password FROM ' . $table) as $r) {
                $p = (string)$r['password'];
                if ($p === '') {
                    continue;
                }
                if (strpos($p, '$2y$') === 0 || strpos($p, '$2a$') === 0) {
                    $bcrypt++;
                } else {
                    $legacy++;
                }
            }
        }
        $flags['passwords_bcrypt'] = $bcrypt;
        $flags['passwords_legacy'] = $legacy;
        $flags['login_table'] = $table;

        // Refunds and credit balances
        $flags['refunds'] = $d->count('tblaccounts', 'amountout > 0') ?: 0;
        $flags['clients_with_credit'] = $d->hasColumn('tblclients', 'credit') ? (int)$d->value('SELECT COUNT(*) FROM tblclients WHERE credit > 0') : 0;

        return $flags;
    }

    public function adminUsernames()
    {
        if (!$this->db->hasTable('tbladmins')) {
            return array();
        }
        return array_map(function ($r) {
            return W2B_Util::norm($r['username']);
        }, $this->db->all('SELECT username FROM tbladmins'));
    }

    /* ---------------- record loaders for matching ---------------- */

    public function loadClients()
    {
        $cur = $this->currencies();
        $out = array();
        foreach ($this->db->all('SELECT id, firstname, lastname, companyname, email, address1, city, postcode, country, status, currency, credit FROM tblclients') as $r) {
            $r['currency_code'] = isset($cur[(int)$r['currency']]) ? $cur[(int)$r['currency']] : null;
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadContacts()
    {
        if (!$this->db->hasTable('tblcontacts')) {
            return array();
        }
        $out = array();
        foreach ($this->db->all('SELECT id, userid, firstname, lastname, companyname, email FROM tblcontacts') as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadProducts()
    {
        $out = array();
        if (!$this->db->hasTable('tblproducts')) {
            return $out;
        }
        foreach ($this->db->all('SELECT id, gid, name, type, servertype FROM tblproducts') as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadServices()
    {
        $out = array();
        if (!$this->db->hasTable('tblhosting')) {
            return $out;
        }
        foreach ($this->db->all('SELECT id, userid, packageid, domain, username, domainstatus, regdate, nextduedate, billingcycle, amount, firstpaymentamount FROM tblhosting') as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadDomains()
    {
        $out = array();
        if (!$this->db->hasTable('tbldomains')) {
            return $out;
        }
        foreach ($this->db->all('SELECT id, userid, domain, registrar, status, registrationdate, expirydate, nextduedate, recurringamount, registrationperiod FROM tbldomains') as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadInvoices()
    {
        $out = array();
        if (!$this->db->hasTable('tblinvoices')) {
            return $out;
        }
        $paid = array();
        if ($this->db->hasTable('tblaccounts')) {
            foreach ($this->db->all('SELECT invoiceid, SUM(amountin) - SUM(amountout) AS p FROM tblaccounts WHERE invoiceid > 0 GROUP BY invoiceid') as $r) {
                $paid[(int)$r['invoiceid']] = (float)$r['p'];
            }
        }
        $numCol = $this->db->hasColumn('tblinvoices', 'invoicenum') ? 'invoicenum' : "''";
        foreach ($this->db->all('SELECT id, userid, ' . $numCol . ' AS invoicenum, date, duedate, datepaid, subtotal, tax, total, credit, status, paymentmethod FROM tblinvoices') as $r) {
            $r['paid_amount'] = isset($paid[(int)$r['id']]) ? $paid[(int)$r['id']] : 0.0;
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadInvoiceItems($invoiceId)
    {
        if (!$this->db->hasTable('tblinvoiceitems')) {
            return array();
        }
        return $this->db->all('SELECT id, type, relid, description, amount, taxed FROM tblinvoiceitems WHERE invoiceid = ?', array($invoiceId));
    }

    public function loadTransactions()
    {
        $out = array();
        if (!$this->db->hasTable('tblaccounts')) {
            return $out;
        }
        $cur = $this->currencies();
        $invUser = array();
        if ($this->db->hasTable('tblinvoices')) {
            foreach ($this->db->all('SELECT id, userid FROM tblinvoices') as $r) {
                $invUser[(int)$r['id']] = (int)$r['userid'];
            }
        }
        foreach ($this->db->all('SELECT id, userid, currency, gateway, date, description, amountin, fees, amountout, transid, invoiceid FROM tblaccounts WHERE amountin > 0') as $r) {
            if ((int)$r['userid'] === 0 && isset($invUser[(int)$r['invoiceid']])) {
                $r['userid'] = $invUser[(int)$r['invoiceid']];
            }
            $r['currency_code'] = isset($cur[(int)$r['currency']]) ? $cur[(int)$r['currency']] : null;
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadTickets()
    {
        $out = array();
        if (!$this->db->hasTable('tbltickets')) {
            return $out;
        }
        $replies = array();
        if ($this->db->hasTable('tblticketreplies')) {
            foreach ($this->db->all('SELECT tid, COUNT(*) AS n FROM tblticketreplies GROUP BY tid') as $r) {
                $replies[(int)$r['tid']] = (int)$r['n'];
            }
        }
        foreach ($this->db->all('SELECT id, tid, userid, did, title, status, date, urgency FROM tbltickets') as $r) {
            $r['messages'] = 1 + (isset($replies[(int)$r['id']]) ? $replies[(int)$r['id']] : 0);
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadTicketReplies($ticketId)
    {
        if (!$this->db->hasTable('tblticketreplies')) {
            return array();
        }
        return $this->db->all('SELECT id, userid, admin, message, date FROM tblticketreplies WHERE tid = ? ORDER BY date, id', array($ticketId));
    }

    public function loadTicketMessage($ticketId)
    {
        return $this->db->one('SELECT message, date, userid FROM tbltickets WHERE id = ?', array($ticketId));
    }

    /* ---------------- helpers used by the migrator / agent ---------------- */

    /** Login password hash of a client (WHMCS 8: tblusers, older: tblclients). */
    public function passwordHashFor($clientId, $email)
    {
        $d = $this->db;
        $hash = null;
        if ($d->hasTable('tblusers_clients') && $d->hasTable('tblusers')) {
            $hash = $d->value('SELECT u.password FROM tblusers_clients uc JOIN tblusers u ON u.id = uc.auth_user_id WHERE uc.client_id = ? ORDER BY uc.owner DESC LIMIT 1', array((int)$clientId));
        }
        if (!$hash && $d->hasTable('tblusers')) {
            $hash = $d->value('SELECT password FROM tblusers WHERE email = ? LIMIT 1', array((string)$email));
        }
        if (!$hash && $d->hasColumn('tblclients', 'password')) {
            $hash = $d->value('SELECT password FROM tblclients WHERE id = ?', array((int)$clientId));
        }
        return $hash ? (string)$hash : null;
    }

    /** Numbers the agent needs for the mapping screen. */
    public function mappingCounts()
    {
        $d = $this->db;
        $out = array('product_services' => array(), 'registrars' => array(), 'gateways' => array(), 'departments' => array());
        if ($d->hasTable('tblhosting')) {
            foreach ($d->all('SELECT packageid, COUNT(*) AS n FROM tblhosting GROUP BY packageid') as $r) {
                $out['product_services'][(int)$r['packageid']] = (int)$r['n'];
            }
        }
        if ($d->hasTable('tbldomains')) {
            foreach ($d->all('SELECT registrar, COUNT(*) AS n FROM tbldomains GROUP BY registrar') as $r) {
                $k = strtolower((string)$r['registrar']);
                $out['registrars'][$k !== '' ? $k : 'none'] = (int)$r['n'];
            }
        }
        if ($d->hasTable('tblaccounts')) {
            foreach ($d->all('SELECT gateway, COUNT(*) AS n FROM tblaccounts WHERE amountin > 0 GROUP BY gateway') as $r) {
                if ((string)$r['gateway'] !== '') {
                    $out['gateways'][strtolower((string)$r['gateway'])] = (int)$r['n'];
                }
            }
        }
        if ($d->hasTable('tblticketdepartments')) {
            foreach ($d->all('SELECT id, name FROM tblticketdepartments') as $r) {
                $out['departments'][(int)$r['id']] = (string)$r['name'];
            }
        }
        return $out;
    }

    public function refundCount()
    {
        return (int)($this->db->count('tblaccounts', 'amountout > 0') ?: 0);
    }

    public function invoiceItemsFor(array $invoiceIds)
    {
        $out = array();
        if (!$this->db->hasTable('tblinvoiceitems') || !$invoiceIds) {
            return $out;
        }
        $ids = implode(',', array_map('intval', $invoiceIds));
        foreach ($this->db->all('SELECT id, invoiceid, type, relid, description, amount, taxed FROM tblinvoiceitems WHERE invoiceid IN (' . $ids . ')') as $r) {
            $inv = (int)$r['invoiceid'];
            unset($r['invoiceid']);
            $out[$inv][] = $r;
        }
        return $out;
    }
}


/* ===================================================================================== */
/*  Remote WHMCS reader (reads through w2b-bridge.php on the WHMCS server)                */
/* ===================================================================================== */

class W2B_RemoteWhmcsReader extends W2B_WhmcsReader
{
    private $url;
    private $key;
    private $cache = array();
    private $ch = null;
    public $calls = 0;

    public function __construct($url, $key)
    {
        $this->url = $url;
        $this->key = $key;
    }

    public function call($fn, array $args = array(), $cache = true)
    {
        $ck = $fn . '|' . json_encode($args);
        if ($cache && array_key_exists($ck, $this->cache)) {
            return $this->cache[$ck];
        }
        $url = $this->url . (strpos($this->url, '?') === false ? '?' : '&') . http_build_query(array('key' => $this->key, 'fn' => $fn, 'args' => json_encode($args)));
        if ($this->ch === null) {
            $this->ch = curl_init();
        }
        curl_setopt_array($this->ch, array(
            CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, CURLOPT_ENCODING => '',
            CURLOPT_HTTPHEADER => array('Accept: application/json'), CURLOPT_FOLLOWLOCATION => false,
        ));
        $body = curl_exec($this->ch);
        $code = (int)curl_getinfo($this->ch, CURLINFO_RESPONSE_CODE);
        $this->calls++;
        if ($body === false) {
            throw new RuntimeException('WHMCS-Brücke nicht erreichbar / bridge unreachable: ' . curl_error($this->ch));
        }
        $data = json_decode((string)$body, true);
        if (!is_array($data) || empty($data['ok'])) {
            $msg = is_array($data) && isset($data['error']) ? $data['error'] : ('HTTP ' . $code . ($code === 404 ? ' (w2b-bridge.php nicht gefunden / not found)' : ''));
            throw new RuntimeException('WHMCS-Brücke / bridge: ' . $msg);
        }
        if ($cache) {
            $this->cache[$ck] = $data['data'];
        }
        return $data['data'];
    }

    public function db()
    {
        throw new LogicException('Remote WHMCS reader has no direct database access');
    }

    public function version() { return $this->call('version'); }
    public function currencies() { return $this->call('currencies'); }
    public function defaultCurrency() { return $this->call('defaultCurrency'); }
    public function counts() { return $this->call('counts'); }
    public function adminUsernames() { return $this->call('adminUsernames'); }
    public function loadClients() { return $this->call('loadClients'); }
    public function loadContacts() { return $this->call('loadContacts'); }
    public function loadProducts() { return $this->call('loadProducts'); }
    public function loadServices() { return $this->call('loadServices'); }
    public function loadDomains() { return $this->call('loadDomains'); }
    public function loadInvoices() { return $this->call('loadInvoices'); }
    public function loadTransactions() { return $this->call('loadTransactions'); }
    public function loadTickets() { return $this->call('loadTickets'); }
    public function mappingCounts() { return $this->call('mappingCounts'); }
    public function refundCount() { return (int)$this->call('refundCount'); }
    public function passwordHashFor($clientId, $email) { return $this->call('passwordHashFor', array((int)$clientId, (string)$email), false); }
    public function loadTicketReplies($ticketId) { return $this->call('loadTicketReplies', array((int)$ticketId), false); }
    public function loadTicketMessage($ticketId) { return $this->call('loadTicketMessage', array((int)$ticketId), false); }

    /** Invoice items are fetched in blocks of 200 invoices to keep the number of requests low. */
    public function loadInvoiceItems($invoiceId)
    {
        $invoiceId = (int)$invoiceId;
        if (!isset($this->cache['items'][$invoiceId])) {
            $all = array_keys($this->loadInvoices());
            sort($all);
            $pos = array_search($invoiceId, $all, true);
            $block = $pos === false ? array($invoiceId) : array_slice($all, $pos, 200);
            $got = $this->call('invoiceItemsFor', array($block), false);
            foreach ($block as $id) {
                $this->cache['items'][$id] = isset($got[$id]) ? $got[$id] : array();
            }
        }
        return $this->cache['items'][$invoiceId];
    }
}

/* ===================================================================================== */
/*  Blesta reader                                                                         */
/* ===================================================================================== */

class W2B_BlestaReader
{
    /** @var W2B_Db */
    private $db;
    private $companyId;

    public function __construct(W2B_Db $db, $companyId = 1)
    {
        $this->db = $db;
        $this->companyId = (int)$companyId;
    }

    public function db()
    {
        return $this->db;
    }

    public function version()
    {
        if ($this->db->hasTable('settings')) {
            $v = $this->db->value("SELECT value FROM settings WHERE " . $this->q('key') . " = 'database_version'");
            if ($v) {
                return $v;
            }
        }
        return null;
    }

    private function q($col)
    {
        return $this->db->driver() === 'mysql' ? '`' . $col . '`' : '"' . $col . '"';
    }

    public function counts()
    {
        $d = $this->db;
        return array(
            'clients' => $d->count('clients'),
            'contacts' => $d->count('contacts'),
            'packages' => $d->count('packages'),
            'services' => $d->count('services'),
            'invoices' => $d->count('invoices'),
            'transactions' => $d->count('transactions'),
            'tickets' => $d->count('support_tickets'),
            'staff' => $d->count('staff'),
        );
    }

    public function installed()
    {
        $d = $this->db;
        $pick = function ($table, $col) use ($d) {
            if (!$d->hasTable($table)) {
                return array();
            }
            $where = $d->hasColumn($table, 'company_id') ? ' WHERE company_id = ' . $this->companyId : '';
            return array_values(array_unique(array_map(function ($r) use ($col) {
                return strtolower($r[$col]);
            }, $d->all('SELECT ' . $col . ' FROM ' . $table . $where))));
        };
        return array(
            'modules' => $pick('modules', 'class'),
            'plugins' => $pick('plugins', 'dir'),
            'gateways' => $pick('gateways', 'class'),
        );
    }

    public function currencies()
    {
        if (!$this->db->hasTable('currencies')) {
            return array();
        }
        $where = $this->db->hasColumn('currencies', 'company_id') ? ' WHERE company_id = ' . $this->companyId : '';
        return array_map(function ($r) {
            return strtoupper($r['code']);
        }, $this->db->all('SELECT code FROM currencies' . $where));
    }

    public function staffUsernames()
    {
        if (!$this->db->hasTable('staff') || !$this->db->hasTable('users')) {
            return array();
        }
        return array_map(function ($r) {
            return W2B_Util::norm($r['username']);
        }, $this->db->all('SELECT u.username FROM staff s JOIN users u ON u.id = s.user_id'));
    }

    public function supportManagerInstalled()
    {
        return $this->db->hasTable('support_tickets');
    }

    public function loadClients()
    {
        $out = array();
        if (!$this->db->hasTable('clients')) {
            return $out;
        }
        $rows = $this->db->all(
            "SELECT c.id, c.id_value, c.user_id, c.status, ct.id AS contact_id, ct.first_name, ct.last_name, ct.company, ct.email, "
            . "ct.address1, ct.city, ct.zip, ct.country FROM clients c "
            . "LEFT JOIN contacts ct ON ct.client_id = c.id AND ct.contact_type = 'primary'"
        );
        foreach ($rows as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadContacts()
    {
        $out = array();
        if (!$this->db->hasTable('contacts')) {
            return $out;
        }
        foreach ($this->db->all("SELECT id, client_id, contact_type, first_name, last_name, company, email FROM contacts WHERE contact_type <> 'primary'") as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadPackages()
    {
        $out = array();
        if (!$this->db->hasTable('packages')) {
            return $out;
        }
        if ($this->db->hasTable('package_names')) {
            $rows = $this->db->all('SELECT p.id, p.module_id, MIN(n.name) AS name FROM packages p LEFT JOIN package_names n ON n.package_id = p.id GROUP BY p.id, p.module_id');
        } else {
            $rows = $this->db->all('SELECT id, module_id, name FROM packages');
        }
        foreach ($rows as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadServices()
    {
        $out = array();
        if (!$this->db->hasTable('services')) {
            return $out;
        }
        $hasPP = $this->db->hasTable('package_pricing');
        $hasMod = $this->db->hasTable('modules');
        $sql = 'SELECT s.id, s.client_id, s.status, s.date_added, s.date_renews'
            . ($hasPP ? ', pp.package_id' : ', NULL AS package_id')
            . ($hasPP && $hasMod ? ', m.class AS module_class' : ', NULL AS module_class')
            . ' FROM services s'
            . ($hasPP ? ' LEFT JOIN package_pricing pp ON pp.id = s.pricing_id' : '')
            . ($hasPP && $hasMod ? ' LEFT JOIN packages p ON p.id = pp.package_id LEFT JOIN modules m ON m.id = p.module_id' : '');
        foreach ($this->db->all($sql) as $r) {
            $r['fields'] = array();
            $out[(int)$r['id']] = $r;
        }
        if ($this->db->hasTable('service_fields')) {
            $enc = $this->db->hasColumn('service_fields', 'encrypted') ? ' WHERE encrypted = 0' : '';
            foreach ($this->db->all('SELECT service_id, ' . $this->q('key') . ' AS k, value FROM service_fields' . $enc) as $f) {
                $sid = (int)$f['service_id'];
                if (isset($out[$sid])) {
                    $out[$sid]['fields'][$f['k']] = (string)$f['value'];
                }
            }
        }
        return $out;
    }

    public function loadInvoices()
    {
        $out = array();
        if (!$this->db->hasTable('invoices')) {
            return $out;
        }
        foreach ($this->db->all('SELECT id, id_value, client_id, date_billed, date_due, date_closed, status, subtotal, total, paid, currency FROM invoices') as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadTransactions()
    {
        $out = array();
        if (!$this->db->hasTable('transactions')) {
            return $out;
        }
        foreach ($this->db->all('SELECT id, client_id, amount, currency, transaction_id, status, date_added FROM transactions') as $r) {
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }

    public function loadTickets()
    {
        $out = array();
        if (!$this->db->hasTable('support_tickets')) {
            return $out;
        }
        $replies = array();
        if ($this->db->hasTable('support_replies')) {
            foreach ($this->db->all("SELECT ticket_id, COUNT(*) AS n FROM support_replies WHERE type = 'reply' GROUP BY ticket_id") as $r) {
                $replies[(int)$r['ticket_id']] = (int)$r['n'];
            }
        }
        foreach ($this->db->all('SELECT id, code, client_id, department_id, summary, status, date_added FROM support_tickets') as $r) {
            $r['messages'] = isset($replies[(int)$r['id']]) ? $replies[(int)$r['id']] : 0;
            $out[(int)$r['id']] = $r;
        }
        return $out;
    }
}

/* ===================================================================================== */
/*  Matcher (duplicate detection)                                                         */
/* ===================================================================================== */

class W2B_Matcher
{
    const NEW_ = 'new';
    const IDENTICAL = 'identical';
    const CHANGED = 'changed';
    const UNCLEAR = 'unclear';

    public static $types = array('clients', 'contacts', 'products', 'services', 'domains', 'invoices', 'transactions', 'tickets');

    /** Module classes in Blesta that represent domain registrations. */
    public static $registrarClasses = array('generic_domains', 'enom', 'logicboxes', 'namesilo', 'opensrs', 'internetbs', 'namecheap', 'cloudflare', 'openprovider', 'hexonet', 'centralnic', 'domains');

    private $w;
    private $b;
    private $fixed;
    private $results = array();
    private $map = array();
    private $claimed = array();

    /**
     * @param array $whmcs  type => records (from W2B_WhmcsReader::load*)
     * @param array $blesta type => records (from W2B_BlestaReader::load*); for products use 'packages'
     * @param array $fixedMapping type => [whmcs_id => blesta_id] from a persistent mapping table (wins over heuristics)
     */
    public function __construct(array $whmcs, array $blesta, array $fixedMapping = array())
    {
        $this->w = $whmcs;
        $this->b = $blesta;
        $this->fixed = $fixedMapping;
    }

    public static function fromReaders(W2B_WhmcsReader $w, W2B_BlestaReader $b, array $fixedMapping = array())
    {
        $wd = array(
            'clients' => $w->loadClients(), 'contacts' => $w->loadContacts(), 'products' => $w->loadProducts(),
            'services' => $w->loadServices(), 'domains' => $w->loadDomains(), 'invoices' => $w->loadInvoices(),
            'transactions' => $w->loadTransactions(), 'tickets' => $w->loadTickets(),
        );
        $bd = array(
            'clients' => $b->loadClients(), 'contacts' => $b->loadContacts(), 'packages' => $b->loadPackages(),
            'services' => $b->loadServices(), 'invoices' => $b->loadInvoices(), 'transactions' => $b->loadTransactions(),
            'tickets' => $b->loadTickets(),
        );
        return new self($wd, $bd, $fixedMapping);
    }

    public function run()
    {
        $this->matchClients();
        $this->matchContacts();
        $this->matchProducts();
        $this->matchServicesAndDomains();
        $this->matchInvoices();
        $this->matchTransactions();
        $this->matchTickets();
        return $this->results;
    }

    public function results()
    {
        return $this->results;
    }

    /** whmcs_id => blesta_id for matched (identical/changed) records. */
    public function mapping($type)
    {
        return isset($this->map[$type]) ? $this->map[$type] : array();
    }

    public function blestaOnly($type)
    {
        $btype = $type === 'products' ? 'packages' : ($type === 'domains' ? null : $type);
        if ($type === 'domains' || $type === 'services') {
            // services and domains share the Blesta services table
            $all = isset($this->b['services']) ? array_keys($this->b['services']) : array();
            $claimed = isset($this->claimed['services']) ? $this->claimed['services'] : array();
            $left = array_diff($all, array_keys($claimed));
            $isDomain = array();
            foreach ($left as $id) {
                $isDomain[$id] = $this->isDomainService($this->b['services'][$id]);
            }
            return count(array_filter($left, function ($id) use ($isDomain, $type) {
                return $type === 'domains' ? $isDomain[$id] : !$isDomain[$id];
            }));
        }
        $all = isset($this->b[$btype]) ? array_keys($this->b[$btype]) : array();
        $claimed = isset($this->claimed[$btype]) ? $this->claimed[$btype] : array();
        return count(array_diff($all, array_keys($claimed)));
    }

    public function summary($includeIds = true)
    {
        $out = array();
        foreach (self::$types as $type) {
            $rows = isset($this->results[$type]) ? $this->results[$type] : array();
            $s = array('total' => count($rows), 'new' => 0, 'identical' => 0, 'changed' => 0, 'unclear' => 0,
                'blesta_only' => $this->blestaOnly($type), 'changed_fields' => array(), 'reasons' => array());
            if ($includeIds) {
                $s['ids'] = array('changed' => array(), 'unclear' => array());
            }
            foreach ($rows as $wid => $r) {
                $s[$r['status']]++;
                if ($r['status'] === self::CHANGED) {
                    foreach ($r['changed'] as $f) {
                        $s['changed_fields'][$f] = (isset($s['changed_fields'][$f]) ? $s['changed_fields'][$f] : 0) + 1;
                    }
                }
                if (!empty($r['reason']) && $r['status'] !== self::IDENTICAL) {
                    $s['reasons'][$r['reason']] = (isset($s['reasons'][$r['reason']]) ? $s['reasons'][$r['reason']] : 0) + 1;
                }
                if ($includeIds && in_array($r['status'], array(self::CHANGED, self::UNCLEAR), true)) {
                    $s['ids'][$r['status']][] = $wid;
                }
            }
            $out[$type] = $s;
        }
        return $out;
    }

    /* ---------------- internals ---------------- */

    private function put($type, $wid, $status, $bid = null, $changed = array(), $reason = null)
    {
        $this->results[$type][$wid] = array('status' => $status, 'blesta_id' => $bid, 'changed' => $changed, 'reason' => $reason);
        if ($bid !== null && in_array($status, array(self::IDENTICAL, self::CHANGED), true)) {
            $this->map[$type][$wid] = $bid;
            $btype = $type === 'products' ? 'packages' : ($type === 'domains' ? 'services' : $type);
            $this->claimed[$btype][$bid] = $wid;
        }
    }

    private function fixedFor($type, $wid, $btable)
    {
        if (isset($this->fixed[$type][$wid])) {
            $bid = (int)$this->fixed[$type][$wid];
            if (isset($this->b[$btable][$bid])) {
                return $bid;
            }
        }
        return null;
    }

    private function isClaimed($btype, $bid)
    {
        return isset($this->claimed[$btype][$bid]);
    }

    /** Resolve a WHMCS client id to [state, blesta_id]: state = mapped|new|unclear|unknown */
    private function clientState($wClientId)
    {
        $wClientId = (int)$wClientId;
        if (isset($this->map['clients'][$wClientId])) {
            return array('mapped', $this->map['clients'][$wClientId]);
        }
        if (isset($this->results['clients'][$wClientId])) {
            return array($this->results['clients'][$wClientId]['status'], null);
        }
        return array('unknown', null);
    }

    /** For dependent records: decide new/unclear when the owner client is not mapped. */
    private function dependentFallback($type, $wid, $state)
    {
        if ($state === self::NEW_) {
            $this->put($type, $wid, self::NEW_, null, array(), 'client_new');
        } elseif ($state === self::UNCLEAR) {
            $this->put($type, $wid, self::UNCLEAR, null, array(), 'client_unclear');
        } else {
            $this->put($type, $wid, self::UNCLEAR, null, array(), 'client_missing');
        }
    }

    private static function clientStatus($whmcs)
    {
        $s = strtolower((string)$whmcs);
        return $s === 'active' ? 'active' : ($s === 'fraud' ? 'fraud' : 'inactive');
    }

    private function matchClients()
    {
        $byEmail = array();
        $byNameZip = array();
        foreach ($this->b['clients'] as $bid => $bc) {
            $byEmail[W2B_Util::normEmail($bc['email'])][] = $bid;
            $byNameZip[W2B_Util::norm($bc['first_name'] . ' ' . $bc['last_name'] . '|' . $bc['zip'])][] = $bid;
        }
        foreach ($this->w['clients'] as $wid => $wc) {
            $bid = $this->fixedFor('clients', $wid, 'clients');
            $reason = $bid ? 'mapping' : null;
            if (!$bid) {
                $email = W2B_Util::normEmail($wc['email']);
                $cands = ($email !== '' && isset($byEmail[$email])) ? $byEmail[$email] : array();
                $cands = array_values(array_filter($cands, function ($id) {
                    return !$this->isClaimed('clients', $id);
                }));
                if (count($cands) > 1) {
                    $this->put('clients', $wid, self::UNCLEAR, null, array(), 'email_multiple');
                    continue;
                }
                if (count($cands) === 1) {
                    $bc = $this->b['clients'][$cands[0]];
                    $sameName = W2B_Util::norm($bc['last_name']) === W2B_Util::norm($wc['lastname'])
                        || ($wc['companyname'] !== '' && W2B_Util::norm($bc['company']) === W2B_Util::norm($wc['companyname']));
                    if (!$sameName) {
                        $this->put('clients', $wid, self::UNCLEAR, $cands[0], array(), 'email_same_name_differs');
                        continue;
                    }
                    $bid = $cands[0];
                    $reason = 'email';
                } else {
                    $key = W2B_Util::norm($wc['firstname'] . ' ' . $wc['lastname'] . '|' . $wc['postcode']);
                    if (isset($byNameZip[$key])) {
                        $this->put('clients', $wid, self::UNCLEAR, $byNameZip[$key][0], array(), 'name_zip_only');
                        continue;
                    }
                    $this->put('clients', $wid, self::NEW_);
                    continue;
                }
            }
            $bc = $this->b['clients'][$bid];
            $changed = array();
            $pairs = array(
                'first_name' => array($wc['firstname'], $bc['first_name']),
                'last_name' => array($wc['lastname'], $bc['last_name']),
                'company' => array($wc['companyname'], $bc['company']),
                'email' => array($wc['email'], $bc['email']),
                'address1' => array($wc['address1'], $bc['address1']),
                'city' => array($wc['city'], $bc['city']),
                'zip' => array($wc['postcode'], $bc['zip']),
                'country' => array($wc['country'], $bc['country']),
            );
            foreach ($pairs as $f => $p) {
                if (W2B_Util::norm($p[0]) !== W2B_Util::norm($p[1])) {
                    $changed[] = $f;
                }
            }
            if (self::clientStatus($wc['status']) !== strtolower((string)$bc['status'])) {
                $changed[] = 'status';
            }
            $this->put('clients', $wid, $changed ? self::CHANGED : self::IDENTICAL, $bid, $changed, $reason);
        }
    }

    private function matchContacts()
    {
        $byClient = array();
        foreach ($this->b['contacts'] as $bid => $bc) {
            $byClient[(int)$bc['client_id']][W2B_Util::normEmail($bc['email'])][] = $bid;
        }
        foreach ($this->w['contacts'] as $wid => $wc) {
            $bid = $this->fixedFor('contacts', $wid, 'contacts');
            if (!$bid) {
                list($state, $bClient) = $this->clientState($wc['userid']);
                if ($state !== 'mapped') {
                    $this->dependentFallback('contacts', $wid, $state);
                    continue;
                }
                $email = W2B_Util::normEmail($wc['email']);
                $cands = isset($byClient[$bClient][$email]) ? $byClient[$bClient][$email] : array();
                $cands = array_values(array_filter($cands, function ($id) {
                    return !$this->isClaimed('contacts', $id);
                }));
                if (!$cands) {
                    $this->put('contacts', $wid, self::NEW_);
                    continue;
                }
                $bid = $cands[0];
            }
            $bc = $this->b['contacts'][$bid];
            $changed = array();
            foreach (array('first_name' => 'firstname', 'last_name' => 'lastname', 'company' => 'companyname') as $bf => $wf) {
                if (W2B_Util::norm($bc[$bf]) !== W2B_Util::norm($wc[$wf])) {
                    $changed[] = $bf;
                }
            }
            $this->put('contacts', $wid, $changed ? self::CHANGED : self::IDENTICAL, $bid, $changed);
        }
    }

    private function matchProducts()
    {
        $byName = array();
        foreach ($this->b['packages'] as $bid => $bp) {
            $byName[W2B_Util::norm($bp['name'])][] = $bid;
        }
        foreach ($this->w['products'] as $wid => $wp) {
            $bid = $this->fixedFor('products', $wid, 'packages');
            if (!$bid) {
                $n = W2B_Util::norm($wp['name']);
                if (isset($byName[$n])) {
                    $free = array_values(array_filter($byName[$n], function ($id) {
                        return !$this->isClaimed('packages', $id);
                    }));
                    $bid = $free ? $free[0] : $byName[$n][0];
                }
            }
            if ($bid) {
                $this->put('products', $wid, self::IDENTICAL, $bid);
            } else {
                $this->put('products', $wid, self::NEW_, null, array(), 'no_package');
            }
        }
    }

    private function isDomainService(array $bs)
    {
        $cls = strtolower((string)$bs['module_class']);
        if ($cls !== '' && in_array($cls, self::$registrarClasses, true)) {
            return true;
        }
        return $cls === '' && isset($bs['fields']['domain']) && count($bs['fields']) <= 3;
    }

    public static function serviceStatus($whmcs)
    {
        $s = strtolower((string)$whmcs);
        $map = array('pending' => 'pending', 'active' => 'active', 'suspended' => 'suspended',
            'terminated' => 'canceled', 'cancelled' => 'canceled', 'fraud' => 'canceled', 'completed' => 'canceled');
        return isset($map[$s]) ? $map[$s] : 'pending';
    }

    public static function domainStatus($whmcs)
    {
        $s = strtolower((string)$whmcs);
        $map = array('active' => 'active', 'pending' => 'pending', 'pending registration' => 'pending', 'pending transfer' => 'pending',
            'grace' => 'active', 'redemption' => 'suspended', 'expired' => 'canceled', 'transferred away' => 'canceled',
            'cancelled' => 'canceled', 'fraud' => 'canceled');
        return isset($map[$s]) ? $map[$s] : 'pending';
    }

    private function matchServicesAndDomains()
    {
        $bByClient = array();
        foreach ($this->b['services'] as $bid => $bs) {
            $bByClient[(int)$bs['client_id']][] = $bid;
        }
        $productMap = $this->mapping('products');

        // Domains first: the strongest key is the domain name.
        foreach ($this->w['domains'] as $wid => $wd) {
            $bid = $this->fixedFor('domains', $wid, 'services');
            if (!$bid) {
                list($state, $bClient) = $this->clientState($wd['userid']);
                if ($state !== 'mapped') {
                    $this->dependentFallback('domains', $wid, $state);
                    continue;
                }
                $dn = W2B_Util::norm($wd['domain']);
                foreach (isset($bByClient[$bClient]) ? $bByClient[$bClient] : array() as $cand) {
                    $bs = $this->b['services'][$cand];
                    if ($this->isClaimed('services', $cand) || !$this->isDomainService($bs)) {
                        continue;
                    }
                    foreach ($bs['fields'] as $v) {
                        if (W2B_Util::norm($v) === $dn) {
                            $bid = $cand;
                            break 2;
                        }
                    }
                }
                if (!$bid) {
                    $this->put('domains', $wid, self::NEW_);
                    continue;
                }
            }
            $bs = $this->b['services'][$bid];
            $changed = array();
            if (self::domainStatus($wd['status']) !== strtolower($bs['status'])) {
                $changed[] = 'status';
            }
            if (in_array(strtolower($bs['status']), array('active', 'suspended'), true)
                && !W2B_Util::sameDay($wd['nextduedate'], $bs['date_renews'])) {
                $changed[] = 'date_renews';
            }
            $this->put('domains', $wid, $changed ? self::CHANGED : self::IDENTICAL, $bid, $changed);
        }

        foreach ($this->w['services'] as $wid => $wh) {
            $bid = $this->fixedFor('services', $wid, 'services');
            if (!$bid) {
                list($state, $bClient) = $this->clientState($wh['userid']);
                if ($state !== 'mapped') {
                    $this->dependentFallback('services', $wid, $state);
                    continue;
                }
                $keys = array_filter(array(W2B_Util::norm($wh['domain']), W2B_Util::norm($wh['username'])));
                $bPkg = isset($productMap[(int)$wh['packageid']]) ? (int)$productMap[(int)$wh['packageid']] : null;
                $best = null;
                $bestScore = 0;
                foreach (isset($bByClient[$bClient]) ? $bByClient[$bClient] : array() as $cand) {
                    $bs = $this->b['services'][$cand];
                    if ($this->isClaimed('services', $cand) || $this->isDomainService($bs)) {
                        continue;
                    }
                    $score = 0;
                    foreach ($bs['fields'] as $v) {
                        if ($keys && in_array(W2B_Util::norm($v), $keys, true)) {
                            $score += 2;
                            break;
                        }
                    }
                    $samePkg = $bPkg !== null && (int)$bs['package_id'] === $bPkg;
                    if ($samePkg) {
                        $score += 1;
                    }
                    // Services without domain/username: same package + same start date
                    if (!$keys && $samePkg && W2B_Util::sameDay($wh['regdate'], $bs['date_added'])) {
                        $score += 2;
                    }
                    if ($score > $bestScore) {
                        $best = $cand;
                        $bestScore = $score;
                    }
                }
                if ($best === null || $bestScore < 2) {
                    $this->put('services', $wid, self::NEW_, null, array(), $bPkg === null ? 'no_package' : null);
                    continue;
                }
                $bid = $best;
            }
            $bs = $this->b['services'][$bid];
            $changed = array();
            if (self::serviceStatus($wh['domainstatus']) !== strtolower($bs['status'])) {
                $changed[] = 'status';
            }
            if (in_array(strtolower($bs['status']), array('active', 'suspended'), true)
                && strtolower((string)$wh['billingcycle']) !== 'one time' && strtolower((string)$wh['billingcycle']) !== 'free account'
                && !W2B_Util::sameDay($wh['nextduedate'], $bs['date_renews'])) {
                $changed[] = 'date_renews';
            }
            $this->put('services', $wid, $changed ? self::CHANGED : self::IDENTICAL, $bid, $changed);
        }
    }

    /** Normalised invoice state: paid|unpaid|cancelled|draft|refunded */
    public static function whmcsInvoiceState($status)
    {
        $s = strtolower((string)$status);
        $map = array('paid' => 'paid', 'unpaid' => 'unpaid', 'overdue' => 'unpaid', 'collections' => 'unpaid', 'payment pending' => 'unpaid',
            'cancelled' => 'cancelled', 'draft' => 'draft', 'refunded' => 'refunded');
        return isset($map[$s]) ? $map[$s] : 'unpaid';
    }

    public static function blestaInvoiceState(array $bi)
    {
        $s = strtolower((string)$bi['status']);
        if ($s === 'void') {
            return 'cancelled';
        }
        if ($s === 'draft') {
            return 'draft';
        }
        return !empty($bi['date_closed']) ? 'paid' : 'unpaid';
    }

    private function matchInvoices()
    {
        $bByClient = array();
        foreach ($this->b['invoices'] as $bid => $bi) {
            $bByClient[(int)$bi['client_id']][] = $bid;
        }
        foreach ($this->w['invoices'] as $wid => $wi) {
            $bid = $this->fixedFor('invoices', $wid, 'invoices');
            if (!$bid) {
                list($state, $bClient) = $this->clientState($wi['userid']);
                if ($state !== 'mapped') {
                    $this->dependentFallback('invoices', $wid, $state);
                    continue;
                }
                $num = W2B_Util::digits($wi['invoicenum'] !== '' && $wi['invoicenum'] !== null ? $wi['invoicenum'] : $wi['id']);
                $byNum = null;
                $byDate = array();
                foreach (isset($bByClient[$bClient]) ? $bByClient[$bClient] : array() as $cand) {
                    if ($this->isClaimed('invoices', $cand)) {
                        continue;
                    }
                    $bi = $this->b['invoices'][$cand];
                    if ($num !== '' && W2B_Util::digits($bi['id_value']) === $num) {
                        $byNum = $cand;
                        break;
                    }
                    if (W2B_Util::sameDay($wi['date'], $bi['date_billed']) && W2B_Util::moneyEq($wi['total'], $bi['total'])) {
                        $byDate[] = $cand;
                    }
                }
                if ($byNum) {
                    $bid = $byNum;
                } elseif (count($byDate) === 1) {
                    $bid = $byDate[0];
                } elseif (count($byDate) > 1) {
                    $this->put('invoices', $wid, self::UNCLEAR, null, array(), 'date_amount_multiple');
                    continue;
                } else {
                    $this->put('invoices', $wid, self::NEW_);
                    continue;
                }
            }
            $bi = $this->b['invoices'][$bid];
            $changed = array();
            $ws = self::whmcsInvoiceState($wi['status']);
            $bs = self::blestaInvoiceState($bi);
            if ($ws !== $bs && !($ws === 'refunded' && $bs === 'paid')) {
                $changed[] = 'status';
            }
            if (!W2B_Util::moneyEq($wi['total'], $bi['total'])) {
                $changed[] = 'total';
            }
            if (!W2B_Util::moneyEq($wi['paid_amount'], $bi['paid'])) {
                $changed[] = 'paid';
            }
            $this->put('invoices', $wid, $changed ? self::CHANGED : self::IDENTICAL, $bid, $changed);
        }
    }

    private function matchTransactions()
    {
        $bByTxn = array();
        $bByClient = array();
        foreach ($this->b['transactions'] as $bid => $bt) {
            if ((string)$bt['transaction_id'] !== '') {
                $bByTxn[(string)$bt['transaction_id']][] = $bid;
            }
            $bByClient[(int)$bt['client_id']][] = $bid;
        }
        foreach ($this->w['transactions'] as $wid => $wt) {
            $bid = $this->fixedFor('transactions', $wid, 'transactions');
            if (!$bid) {
                list($state, $bClient) = $this->clientState($wt['userid']);
                if ($state !== 'mapped') {
                    $this->dependentFallback('transactions', $wid, $state);
                    continue;
                }
                $txn = (string)$wt['transid'];
                if ($txn !== '' && isset($bByTxn[$txn])) {
                    foreach ($bByTxn[$txn] as $cand) {
                        if (!$this->isClaimed('transactions', $cand) && (int)$this->b['transactions'][$cand]['client_id'] === (int)$bClient) {
                            $bid = $cand;
                            break;
                        }
                    }
                }
                if (!$bid) {
                    $hits = array();
                    foreach (isset($bByClient[$bClient]) ? $bByClient[$bClient] : array() as $cand) {
                        $bt = $this->b['transactions'][$cand];
                        if (!$this->isClaimed('transactions', $cand) && W2B_Util::sameDay($wt['date'], $bt['date_added'])
                            && W2B_Util::moneyEq($wt['amountin'], $bt['amount'])) {
                            $hits[] = $cand;
                        }
                    }
                    if (count($hits) === 1) {
                        $bid = $hits[0];
                    } elseif (count($hits) > 1) {
                        $this->put('transactions', $wid, self::UNCLEAR, null, array(), 'date_amount_multiple');
                        continue;
                    } else {
                        $this->put('transactions', $wid, self::NEW_);
                        continue;
                    }
                }
            }
            $bt = $this->b['transactions'][$bid];
            $changed = W2B_Util::moneyEq($wt['amountin'], $bt['amount']) ? array() : array('amount');
            $this->put('transactions', $wid, $changed ? self::CHANGED : self::IDENTICAL, $bid, $changed);
        }
    }

    public static function ticketClosed($whmcsStatus)
    {
        return strtolower((string)$whmcsStatus) === 'closed';
    }

    private function matchTickets()
    {
        if (empty($this->w['tickets'])) {
            $this->results['tickets'] = array();
            return;
        }
        $bByCode = array();
        $bByClient = array();
        foreach ($this->b['tickets'] as $bid => $bt) {
            $bByCode[W2B_Util::digits($bt['code'])][] = $bid;
            $bByClient[(int)$bt['client_id']][] = $bid;
        }
        foreach ($this->w['tickets'] as $wid => $wt) {
            $bid = $this->fixedFor('tickets', $wid, 'tickets');
            if (!$bid) {
                list($state, $bClient) = $this->clientState($wt['userid']);
                if ((int)$wt['userid'] === 0) {
                    // Guest tickets: match by code / subject+date only
                    $state = 'mapped';
                    $bClient = null;
                }
                if ($state !== 'mapped') {
                    $this->dependentFallback('tickets', $wid, $state);
                    continue;
                }
                $code = W2B_Util::digits($wt['tid']);
                if ($code !== '' && isset($bByCode[$code])) {
                    foreach ($bByCode[$code] as $cand) {
                        if (!$this->isClaimed('tickets', $cand)) {
                            $bid = $cand;
                            break;
                        }
                    }
                }
                if (!$bid) {
                    $pool = $bClient !== null && isset($bByClient[$bClient]) ? $bByClient[$bClient] : array();
                    foreach ($pool as $cand) {
                        $bt = $this->b['tickets'][$cand];
                        if (!$this->isClaimed('tickets', $cand) && W2B_Util::norm($bt['summary']) === W2B_Util::norm($wt['title'])
                            && W2B_Util::sameDay($bt['date_added'], $wt['date'])) {
                            $bid = $cand;
                            break;
                        }
                    }
                }
                if (!$bid) {
                    $this->put('tickets', $wid, self::NEW_);
                    continue;
                }
            }
            $bt = $this->b['tickets'][$bid];
            $changed = array();
            if (self::ticketClosed($wt['status']) !== (strtolower($bt['status']) === 'closed')) {
                $changed[] = 'status';
            }
            if ((int)$wt['messages'] > (int)$bt['messages']) {
                $changed[] = 'replies';
            }
            $this->put('tickets', $wid, $changed ? self::CHANGED : self::IDENTICAL, $bid, $changed);
        }
    }
}

/* ===================================================================================== */
/*  Scanner (builds the JSON report)                                                      */
/* ===================================================================================== */

class W2B_Scanner
{
    public static function scan(W2B_WhmcsReader $w, W2B_BlestaReader $b = null, $includeIds = true)
    {
        $report = array(
            'schema' => 'w2b-scan/1',
            'scanner_version' => W2B_SCANNER_VERSION,
            'generated_at' => gmdate('c'),
            'php_version' => PHP_VERSION,
            'whmcs' => array(
                'version' => $w->version(),
                'db_driver' => $w->db()->driver(),
                'counts' => $w->counts(),
                'currencies' => array_values($w->currencies()),
                'default_currency' => $w->defaultCurrency(),
                'collations' => $w->collations(),
                'modules' => $w->modules(),
                'hooks' => $w->hooks(),
                'custom_templates' => $w->customTemplates(),
                'flags' => $w->flags(),
            ),
            'blesta' => null,
            'comparison' => null,
        );

        if ($b) {
            $staff = $b->staffUsernames();
            $admins = $w->adminUsernames();
            $report['blesta'] = array(
                'version' => $b->version(),
                'counts' => $b->counts(),
                'currencies' => $b->currencies(),
                'installed' => $b->installed(),
                'support_manager' => $b->supportManagerInstalled(),
                'staff_username_conflicts' => count(array_intersect($staff, $admins)),
            );
            $m = W2B_Matcher::fromReaders($w, $b);
            $m->run();
            $report['comparison'] = $m->summary($includeIds);
        }
        return $report;
    }
}

/* ===================================================================================== */
/*  Config file readers                                                                   */
/* ===================================================================================== */

class W2B_Config
{
    /** Reads WHMCS configuration.php ($db_host, $db_name, ...). */
    public static function whmcs($path)
    {
        $file = rtrim($path, '/') . '/configuration.php';
        if (!is_readable($file)) {
            throw new RuntimeException("WHMCS configuration.php not readable: $file");
        }
        $vars = (function ($__file) {
            include $__file;
            return get_defined_vars();
        })($file);
        $host = isset($vars['db_host']) ? $vars['db_host'] : 'localhost';
        $port = !empty($vars['db_port']) ? ';port=' . $vars['db_port'] : '';
        return array(
            'dsn' => 'mysql:host=' . $host . $port . ';dbname=' . $vars['db_name'],
            'user' => $vars['db_username'],
            'pass' => $vars['db_password'],
            'cc_hash' => isset($vars['cc_encryption_hash']) ? $vars['cc_encryption_hash'] : null,
        );
    }

    /** Reads Blesta config/blesta.php (Blesta.database_info) via pattern matching. */
    public static function blesta($path)
    {
        $file = rtrim($path, '/') . '/config/blesta.php';
        if (!is_readable($file)) {
            throw new RuntimeException("Blesta config/blesta.php not readable: $file");
        }
        $src = file_get_contents($file);
        $get = function ($key) use ($src) {
            if (preg_match('/[\'"]' . $key . '[\'"]\s*=>\s*[\'"]([^\'"]*)[\'"]/', $src, $m)) {
                return $m[1];
            }
            return null;
        };
        $host = $get('host') ?: 'localhost';
        $port = $get('port');
        return array(
            'dsn' => 'mysql:host=' . $host . ($port ? ';port=' . $port : '') . ';dbname=' . $get('database'),
            'user' => $get('user'),
            'pass' => $get('pass'),
        );
    }
}

/* ===================================================================================== */
/*  CLI entry point                                                                       */
/* ===================================================================================== */

if (!defined('W2B_SCANNER_LIB') && PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
    $opt = getopt('', array(
        'whmcs-path:', 'whmcs-dsn:', 'whmcs-user:', 'whmcs-pass:',
        'blesta-path:', 'blesta-dsn:', 'blesta-user:', 'blesta-pass:', 'blesta-company:',
        'out:', 'no-ids', 'help',
    ));
    if (isset($opt['help']) || (!isset($opt['whmcs-path']) && !isset($opt['whmcs-dsn']))) {
        fwrite(STDERR, "WHMCS -> Blesta Migration Scanner " . W2B_SCANNER_VERSION . "\n\n"
            . "  php w2b-scan.php --whmcs-path=/path/to/whmcs [--blesta-path=/path/to/blesta] [--out=scan.json]\n"
            . "  php w2b-scan.php --whmcs-dsn='mysql:host=...;dbname=...' --whmcs-user=.. --whmcs-pass=..\n"
            . "                   [--blesta-dsn=.. --blesta-user=.. --blesta-pass=..] [--out=scan.json]\n\n"
            . "Read-only. Output contains no personal data (only counts, module names, record IDs).\n"
            . "Use --no-ids to also omit record IDs.\n");
        exit(1);
    }
    try {
        if (isset($opt['whmcs-dsn'])) {
            $wc = array('dsn' => $opt['whmcs-dsn'], 'user' => isset($opt['whmcs-user']) ? $opt['whmcs-user'] : null,
                'pass' => isset($opt['whmcs-pass']) ? $opt['whmcs-pass'] : null);
        } else {
            $wc = W2B_Config::whmcs($opt['whmcs-path']);
        }
        $whmcs = new W2B_WhmcsReader(W2B_Db::connect($wc['dsn'], $wc['user'], $wc['pass']),
            isset($opt['whmcs-path']) ? $opt['whmcs-path'] : null);

        $blesta = null;
        if (isset($opt['blesta-dsn']) || isset($opt['blesta-path'])) {
            if (isset($opt['blesta-dsn'])) {
                $bc = array('dsn' => $opt['blesta-dsn'], 'user' => isset($opt['blesta-user']) ? $opt['blesta-user'] : null,
                    'pass' => isset($opt['blesta-pass']) ? $opt['blesta-pass'] : null);
            } else {
                $bc = W2B_Config::blesta($opt['blesta-path']);
            }
            $blesta = new W2B_BlestaReader(W2B_Db::connect($bc['dsn'], $bc['user'], $bc['pass']),
                isset($opt['blesta-company']) ? (int)$opt['blesta-company'] : 1);
        }

        $report = W2B_Scanner::scan($whmcs, $blesta, !isset($opt['no-ids']));
        $json = json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if (isset($opt['out'])) {
            file_put_contents($opt['out'], $json);
            fwrite(STDERR, "Scan written to " . $opt['out'] . "\n");
        } else {
            echo $json, "\n";
        }

        $c = $report['whmcs']['counts'];
        fwrite(STDERR, sprintf("WHMCS %s: %d clients, %d services, %d domains, %d invoices, %d tickets, %d modules\n",
            $report['whmcs']['version'], $c['clients'], $c['services'], $c['domains'], $c['invoices'], (int)$c['tickets'],
            count($report['whmcs']['modules'])));
        if ($report['comparison']) {
            foreach ($report['comparison'] as $type => $s) {
                fwrite(STDERR, sprintf("  %-13s new %5d | identical %5d | changed %5d | unclear %5d | only in Blesta %5d\n",
                    $type, $s['new'], $s['identical'], $s['changed'], $s['unclear'], $s['blesta_only']));
            }
        }
        fwrite(STDERR, "Upload the JSON file on the migration website to get your report.\n");
    } catch (Exception $e) {
        fwrite(STDERR, 'ERROR: ' . $e->getMessage() . "\n");
        exit(2);
    }
}


/* ===================================================================================== */
/*  Browser mode                                                                          */
/* ===================================================================================== */

if (!defined('W2B_SCANNER_LIB') && PHP_SAPI !== 'cli') {
    w2b_web_mode();
}

function w2b_web_mode()
{
    header('X-Robots-Tag: noindex, nofollow');
    header('Cache-Control: no-store');
    header('X-Frame-Options: DENY');
    $lang = (isset($_REQUEST['lang']) && $_REQUEST['lang'] === 'en') ? 'en' : 'de';
    $T = array(
        'de' => array(
            'title' => 'WHMCS → Blesta Scanner',
            'nokey' => 'Dieser Scanner wurde nicht über die Migrations-Website heruntergeladen und hat darum keinen Zugriffsschlüssel. Bitte laden Sie ihn dort erneut herunter.',
            'badkey' => 'Zugriff verweigert. Der Schlüssel in der Adresse fehlt oder ist falsch. Kopieren Sie die Adresse genau so, wie sie auf der Migrations-Website steht.',
            'lead' => 'Der Scanner liest Ihre Datenbank nur. Er ändert nichts. Die Datei scan.json enthält keine Namen, Adressen oder E-Mails Ihrer Kunden.',
            'whmcs' => 'Ordner Ihrer WHMCS-Installation',
            'whmcs_hint' => 'Der Ordner, in dem die Datei configuration.php liegt.',
            'found' => 'WHMCS gefunden',
            'notfound' => 'In diesem Ordner liegt keine configuration.php.',
            'blesta' => 'Ordner Ihrer Blesta-Installation (nur falls Blesta schon installiert ist)',
            'blesta_hint' => 'Der Ordner, in dem der Unterordner config mit der Datei blesta.php liegt. Leer lassen, wenn Sie noch kein Blesta haben.',
            'submit' => 'Scan erstellen und scan.json herunterladen',
            'after' => 'Danach: Laden Sie scan.json auf der Migrations-Website hoch und löschen Sie diese Datei (w2b-scan.php) wieder von Ihrem Server.',
            'error' => 'Der Scan ist fehlgeschlagen:',
        ),
        'en' => array(
            'title' => 'WHMCS → Blesta scanner',
            'nokey' => 'This scanner was not downloaded from the migration website and has no access key. Download it there again.',
            'badkey' => 'Access denied. The key in the address is missing or wrong. Copy the address exactly as shown on the migration website.',
            'lead' => 'The scanner only reads your database. It changes nothing. The file scan.json contains no names, addresses or emails of your clients.',
            'whmcs' => 'Folder of your WHMCS installation',
            'whmcs_hint' => 'The folder that contains the file configuration.php.',
            'found' => 'WHMCS found',
            'notfound' => 'There is no configuration.php in this folder.',
            'blesta' => 'Folder of your Blesta installation (only if Blesta is already installed)',
            'blesta_hint' => 'The folder that contains the subfolder config with the file blesta.php. Leave empty if you do not have Blesta yet.',
            'submit' => 'Create scan and download scan.json',
            'after' => 'Next: upload scan.json on the migration website and delete this file (w2b-scan.php) from your server.',
            'error' => 'The scan failed:',
        ),
    );
    $t = $T[$lang];
    $h = function ($s) {
        return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
    };
    $page = function ($body) use ($t, $lang) {
        header('Content-Type: text/html; charset=utf-8');
        echo '<!doctype html><html lang="' . $lang . '"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
            . '<meta name="robots" content="noindex"><title>' . htmlspecialchars($t['title']) . '</title><style>'
            . 'body{font:16px/1.55 system-ui,sans-serif;color:#18223a;background:#f4f6fa;margin:0;padding:32px 16px}'
            . 'main{max-width:640px;margin:auto;background:#fff;border:1px solid #d5dbe6;border-radius:10px;padding:28px}'
            . 'h1{font-size:24px;margin:0 0 8px}label{font-weight:600;display:block;margin:18px 0 6px}'
            . 'input[type=text]{width:100%;box-sizing:border-box;padding:10px;border:1.5px solid #b9c2d3;border-radius:4px;font:inherit}'
            . '.hint{color:#59637a;font-size:14px;margin:6px 0 0}.ok{color:#1d7a4c}.bad{color:#b42d23}'
            . 'button{margin-top:24px;font:600 16px system-ui;padding:13px 20px;border:0;border-radius:4px;background:#ffc20e;color:#3a2b00;cursor:pointer}'
            . '.box{border-left:4px solid #b42d23;background:#fbe7e5;padding:12px 16px;border-radius:4px}'
            . '</style></head><body><main><h1>' . htmlspecialchars($t['title']) . '</h1>' . $body . '</main></body></html>';
        exit;
    };

    if (W2B_WEB_KEY === '' || strpos(W2B_WEB_KEY, '__W2B_') === 0) {   // placeholder not replaced
        http_response_code(403);
        $page('<p class="box">' . $h($t['nokey']) . '</p>');
    }
    $key = isset($_REQUEST['key']) ? (string)$_REQUEST['key'] : '';
    if (!hash_equals(W2B_WEB_KEY, $key)) {
        http_response_code(403);
        $page('<p class="box">' . $h($t['badkey']) . '</p>');
    }

    // Bridge mode: the migration agent in Blesta reads WHMCS data through this file (read-only, fixed functions).
    if (isset($_GET['fn'])) {
        w2b_bridge((string)$_GET['fn'], isset($_GET['args']) ? (string)$_GET['args'] : '[]');
    }

    $guessWhmcs = is_file(__DIR__ . '/configuration.php') ? __DIR__ : (is_file(dirname(__DIR__) . '/configuration.php') ? dirname(__DIR__) : __DIR__);
    $whmcsPath = isset($_POST['whmcs_path']) ? trim((string)$_POST['whmcs_path']) : $guessWhmcs;
    $blestaPath = isset($_POST['blesta_path']) ? trim((string)$_POST['blesta_path']) : '';
    $error = '';

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        @set_time_limit(600);
        @ini_set('memory_limit', '1024M');
        try {
            $wc = W2B_Config::whmcs($whmcsPath);
            $whmcs = new W2B_WhmcsReader(W2B_Db::connect($wc['dsn'], $wc['user'], $wc['pass']), $whmcsPath);
            $blesta = null;
            if ($blestaPath !== '') {
                $bc = W2B_Config::blesta($blestaPath);
                $blesta = new W2B_BlestaReader(W2B_Db::connect($bc['dsn'], $bc['user'], $bc['pass']));
            }
            $json = json_encode(W2B_Scanner::scan($whmcs, $blesta, true), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            header('Content-Type: application/json; charset=utf-8');
            header('Content-Disposition: attachment; filename="scan.json"');
            header('Content-Length: ' . strlen($json));
            echo $json;
            exit;
        } catch (Exception $e) {
            $error = $e->getMessage();
        } catch (Error $e) {
            $error = $e->getMessage();
        }
    }

    $found = is_file(rtrim($whmcsPath, '/') . '/configuration.php');
    $body = '<p>' . $h($t['lead']) . '</p>';
    if ($error !== '') {
        $body .= '<p class="box"><strong>' . $h($t['error']) . '</strong> ' . $h($error) . '</p>';
    }
    $body .= '<form method="post" action="?key=' . $h(rawurlencode(W2B_WEB_KEY)) . '&amp;lang=' . $lang . '">'
        . '<label for="w">' . $h($t['whmcs']) . '</label><input type="text" id="w" name="whmcs_path" value="' . $h($whmcsPath) . '">'
        . '<p class="hint">' . $h($t['whmcs_hint']) . ' ' . ($found ? '<span class="ok">✓ ' . $h($t['found']) . '</span>' : '<span class="bad">' . $h($t['notfound']) . '</span>') . '</p>'
        . '<label for="b">' . $h($t['blesta']) . '</label><input type="text" id="b" name="blesta_path" value="' . $h($blestaPath) . '" placeholder="' . $h(dirname($guessWhmcs)) . '/blesta">'
        . '<p class="hint">' . $h($t['blesta_hint']) . '</p>'
        . '<button type="submit">' . $h($t['submit']) . '</button></form>'
        . '<p class="hint" style="margin-top:20px">' . $h($t['after']) . '</p>';
    $page($body);
}


function w2b_bridge($fn, $argsJson)
{
    $allowed = array(
        'version' => 0, 'currencies' => 0, 'defaultCurrency' => 0, 'counts' => 0, 'adminUsernames' => 0,
        'loadClients' => 0, 'loadContacts' => 0, 'loadProducts' => 0, 'loadServices' => 0, 'loadDomains' => 0,
        'loadInvoices' => 0, 'loadTransactions' => 0, 'loadTickets' => 0, 'mappingCounts' => 0, 'refundCount' => 0,
        'passwordHashFor' => 2, 'loadTicketReplies' => 1, 'loadTicketMessage' => 1, 'invoiceItemsFor' => 1,
    );
    if (extension_loaded('zlib') && !ini_get('zlib.output_compression')) {
        @ob_start('ob_gzhandler');
    }
    header('Content-Type: application/json; charset=utf-8');
    @set_time_limit(300);
    @ini_set('memory_limit', '1024M');
    $out = function ($data, $code) {
        http_response_code($code);
        // Old WHMCS databases sometimes contain invalid UTF-8: replace those bytes instead of failing
        $flags = JSON_UNESCAPED_UNICODE | (defined('JSON_INVALID_UTF8_SUBSTITUTE') ? JSON_INVALID_UTF8_SUBSTITUTE : JSON_PARTIAL_OUTPUT_ON_ERROR);
        echo json_encode($data, $flags);
        exit;
    };
    if (!isset($allowed[$fn])) {
        $out(array('ok' => false, 'error' => 'unknown function'), 400);
    }
    $args = json_decode($argsJson, true);
    if (!is_array($args) || count($args) !== $allowed[$fn]) {
        $out(array('ok' => false, 'error' => 'bad arguments'), 400);
    }
    try {
        $dsn = getenv('W2B_TEST_WHMCS_DSN');
        if ($dsn) {
            $reader = new W2B_WhmcsReader(W2B_Db::connect($dsn));
        } else {
            if (!is_file(__DIR__ . '/configuration.php')) {
                $out(array('ok' => false, 'error' => 'configuration.php not found next to w2b-bridge.php. Upload the file into the WHMCS folder.'), 500);
            }
            $c = W2B_Config::whmcs(__DIR__);
            $reader = new W2B_WhmcsReader(W2B_Db::connect($c['dsn'], $c['user'], $c['pass']), __DIR__);
        }
        $data = call_user_func_array(array($reader, $fn), $args);
        $out(array('ok' => true, 'data' => $data), 200);
    } catch (Exception $e) {
        $out(array('ok' => false, 'error' => $e->getMessage()), 500);
    } catch (Error $e) {
        $out(array('ok' => false, 'error' => $e->getMessage()), 500);
    }
}
