<?php
/**
 * Bichon REST API Client for Web Hosting Panel
 * Wraps the Bichon (https://github.com/rustmailer/bichon) email archival
 * server REST API. Used by the WHP Bichon archival addon to provision and
 * manage IMAP-mirrored mailboxes against an operator-configured Bichon
 * instance.
 *
 * Configuration lives in /docker/whp/settings.json under the top-level
 * "bichon" key. The api_token is stored encrypted using
 * SmtpMailer::encryptPassword() / decryptPassword() (AES-256-CBC keyed off
 * NONCE_SALT).
 */

require_once(__DIR__ . '/smtp_mailer.php');

class BichonClient {

    private static $settings_file = '/docker/whp/settings.json';

    private $url;       // base URL with trailing slash trimmed
    private $token;     // PLAINTEXT bearer token

    /**
     * Check whether Bichon is configured and enabled.
     *
     * @return bool true iff settings.json has bichon.enabled, bichon.url, and bichon.api_token
     */
    public static function isConfigured(): bool {
        if (!file_exists(self::$settings_file)) {
            return false;
        }
        $contents = @file_get_contents(self::$settings_file);
        if ($contents === false) {
            return false;
        }
        $settings = json_decode($contents, true);
        if (!is_array($settings) || !isset($settings['bichon']) || !is_array($settings['bichon'])) {
            return false;
        }
        $b = $settings['bichon'];
        return !empty($b['enabled']) && !empty($b['url']) && !empty($b['api_token']);
    }

    /**
     * Load Bichon configuration with the api_token decrypted.
     *
     * @return array|null ['enabled','url','api_token' (PLAINTEXT),'last_test_ok_at'] or null if missing
     */
    public static function loadConfig(): ?array {
        if (!file_exists(self::$settings_file)) {
            return null;
        }
        $contents = @file_get_contents(self::$settings_file);
        if ($contents === false) {
            return null;
        }
        $settings = json_decode($contents, true);
        if (!is_array($settings) || !isset($settings['bichon']) || !is_array($settings['bichon'])) {
            return null;
        }
        $b = $settings['bichon'];

        $token_plain = '';
        if (!empty($b['api_token'])) {
            $decrypted = SmtpMailer::decryptPassword($b['api_token']);
            if ($decrypted === false) {
                error_log('BichonClient: Failed to decrypt api_token from settings.json');
                $token_plain = '';
            } else {
                $token_plain = $decrypted;
            }
        }

        return array(
            'enabled'         => !empty($b['enabled']),
            'url'             => isset($b['url']) ? (string)$b['url'] : '',
            'api_token'       => $token_plain,
            'last_test_ok_at' => isset($b['last_test_ok_at']) ? (string)$b['last_test_ok_at'] : '',
        );
    }

    /**
     * Constructor.
     *
     * @param array|null $config null => self::loadConfig(); else use supplied
     *                           config as-is (used by the Test Connection button
     *                           before save). Expects 'url' and 'api_token'
     *                           (plaintext) to be present.
     * @throws Exception if url or api_token is missing.
     */
    public function __construct(?array $config = null) {
        if ($config === null) {
            $config = self::loadConfig();
        }
        if (!is_array($config) || empty($config['url']) || empty($config['api_token'])) {
            throw new Exception('BichonClient: missing url or api_token in configuration');
        }

        // Trim trailing slashes so /api/v1/... is a clean concat.
        $this->url   = rtrim((string)$config['url'], '/');
        $this->token = (string)$config['api_token'];
    }

    /**
     * Probe connectivity and auth by listing accounts.
     *
     * @return array ['ok'=>bool, 'msg'=>string, 'http'=>int]
     */
    public function testConnection(): array {
        $res = $this->request('GET', '/api/v1/accounts', null);
        return array(
            'ok'   => $res['ok'],
            'msg'  => $res['ok'] ? 'Connection OK' : $res['msg'],
            'http' => $res['http'],
        );
    }

    /**
     * Create a Bichon mailbox account.
     *
     * Passthrough: any keys provided in $params are forwarded as-is. The
     * Bichon AccountCreateRequest schema is not publicly documented, so do
     * not filter unknown fields.
     *
     * @param array $params associative request body (e.g. imap_host, imap_port, imap_ssl, username, password, label)
     * @return array ['ok','account_id','msg','http','raw']
     */
    public function createAccount(array $params): array {
        $res = $this->request('POST', '/api/v1/account', $params);
        $raw = is_array($res['raw']) ? $res['raw'] : array();

        $account_id = null;
        if ($res['ok']) {
            // Try common id fields first
            if (isset($raw['id']) && is_string($raw['id'])) {
                $account_id = $raw['id'];
            } elseif (isset($raw['id']) && (is_int($raw['id']) || is_numeric($raw['id']))) {
                $account_id = (string)$raw['id'];
            } elseif (isset($raw['account_id']) && (is_string($raw['account_id']) || is_numeric($raw['account_id']))) {
                $account_id = (string)$raw['account_id'];
            } else {
                // Fallback: scan top-level for any string field that looks like an id
                foreach ($raw as $k => $v) {
                    if (!is_string($k)) continue;
                    if (!(is_string($v) || is_numeric($v))) continue;
                    $kl = strtolower($k);
                    if ($kl === 'uuid' || $kl === 'guid' || substr($kl, -3) === '_id' || substr($kl, -2) === 'id') {
                        $account_id = (string)$v;
                        break;
                    }
                }
            }
        }

        return array(
            'ok'         => $res['ok'],
            'account_id' => $account_id,
            'msg'        => $res['msg'],
            'http'       => $res['http'],
            'raw'        => $raw,
        );
    }

    /**
     * Delete a Bichon account.
     *
     * @param string $bichonAccountId
     * @return array ['ok','msg','http']
     */
    public function deleteAccount(string $bichonAccountId): array {
        $res = $this->request('DELETE', '/api/v1/account/' . rawurlencode($bichonAccountId), null);
        return array(
            'ok'   => $res['ok'],
            'msg'  => $res['msg'],
            'http' => $res['http'],
        );
    }

    /**
     * Get a single Bichon account.
     *
     * @param string $bichonAccountId
     * @return array ['ok','msg','http','raw']
     */
    public function getAccount(string $bichonAccountId): array {
        $res = $this->request('GET', '/api/v1/account/' . rawurlencode($bichonAccountId), null);
        return array(
            'ok'   => $res['ok'],
            'msg'  => $res['msg'],
            'http' => $res['http'],
            'raw'  => is_array($res['raw']) ? $res['raw'] : array(),
        );
    }

    /**
     * List all Bichon accounts.
     *
     * @return array ['ok','msg','http','raw']
     */
    public function listAccounts(): array {
        $res = $this->request('GET', '/api/v1/accounts', null);
        return array(
            'ok'   => $res['ok'],
            'msg'  => $res['msg'],
            'http' => $res['http'],
            'raw'  => is_array($res['raw']) ? $res['raw'] : array(),
        );
    }

    /**
     * Create a Bichon RBAC user.
     *
     * Schema (Bichon UserCreateRequest):
     *   - username, email, password (required)
     *   - global_roles (array of u64 role ids)
     *   - account_access_map (BTreeMap<u64,u64>; JSON object with string keys =
     *     account_id, values = role_id integer)
     *   - acl, avatar_base64, description, theme, language (optional)
     *
     * @param array $params associative request body (passthrough)
     * @return array ['ok','user_id','msg','http','raw'] — id extraction same
     *               as createAccount: try 'id' first, then *_id fallbacks.
     */
    public function createUser(array $params): array {
        $res = $this->request('POST', '/api/v1/users', $params);
        $raw = is_array($res['raw']) ? $res['raw'] : array();

        $user_id = null;
        if ($res['ok']) {
            if (isset($raw['id']) && is_string($raw['id'])) {
                $user_id = $raw['id'];
            } elseif (isset($raw['id']) && (is_int($raw['id']) || is_numeric($raw['id']))) {
                $user_id = (string)$raw['id'];
            } elseif (isset($raw['user_id']) && (is_string($raw['user_id']) || is_numeric($raw['user_id']))) {
                $user_id = (string)$raw['user_id'];
            } else {
                foreach ($raw as $k => $v) {
                    if (!is_string($k)) continue;
                    if (!(is_string($v) || is_numeric($v))) continue;
                    $kl = strtolower($k);
                    if ($kl === 'uuid' || $kl === 'guid' || substr($kl, -3) === '_id' || substr($kl, -2) === 'id') {
                        $user_id = (string)$v;
                        break;
                    }
                }
            }
        }

        return array(
            'ok'      => $res['ok'],
            'user_id' => $user_id,
            'msg'     => $res['msg'],
            'http'    => $res['http'],
            'raw'     => $raw,
        );
    }

    /**
     * Update a Bichon user. Bichon uses POST (not PUT) for the update verb.
     * UserUpdateRequest fields are all Option<>; send only what you want to
     * change. Common case: ['password' => $new].
     *
     * @param string $userId
     * @param array  $params associative request body
     * @return array ['ok','msg','http','raw']
     */
    public function updateUser(string $userId, array $params): array {
        $res = $this->request('POST', '/api/v1/users/' . rawurlencode($userId), $params);
        return array(
            'ok'   => $res['ok'],
            'msg'  => $res['msg'],
            'http' => $res['http'],
            'raw'  => is_array($res['raw']) ? $res['raw'] : array(),
        );
    }

    /**
     * Delete a Bichon user (RBAC entity). Does NOT touch the archival account
     * or its data — that's deleteAccount().
     *
     * @param string $userId
     * @return array ['ok','msg','http']
     */
    public function deleteUser(string $userId): array {
        $res = $this->request('DELETE', '/api/v1/users/' . rawurlencode($userId), null);
        return array(
            'ok'   => $res['ok'],
            'msg'  => $res['msg'],
            'http' => $res['http'],
        );
    }

    /**
     * Update a Bichon archival account. Bichon uses POST (not PUT). All
     * AccountUpdateRequest fields are Option<>; common case: refresh the
     * stored IMAP credentials and re-enable on grace-period resume.
     *
     * @param string $accountId
     * @param array  $params    associative request body (passthrough)
     * @return array ['ok','msg','http','raw']
     */
    public function updateAccount(string $accountId, array $params): array {
        $res = $this->request('POST', '/api/v1/account/' . rawurlencode($accountId), $params);
        return array(
            'ok'   => $res['ok'],
            'msg'  => $res['msg'],
            'http' => $res['http'],
            'raw'  => is_array($res['raw']) ? $res['raw'] : array(),
        );
    }

    /**
     * Fetch a Bichon account's download statistics.
     *
     * GET /api/v1/accounts/{id}/download-stats returns a DownloadState whose
     * key fields are `active_session` (null when no sync is currently running)
     * and `last_finished_at` (timestamp of the last completed download). An
     * initial import is considered complete when active_session is null AND
     * last_finished_at is set. Used by the reconcile CLI to throttle the poll
     * interval once a NoSync->IMAP import has finished its history pull.
     *
     * @param string $accountId
     * @return array ['ok','msg','http','raw']
     */
    public function getDownloadStats(string $accountId): array {
        $res = $this->request('GET', '/api/v1/accounts/' . rawurlencode($accountId) . '/download-stats', null);
        return array(
            'ok'   => $res['ok'],
            'msg'  => $res['msg'],
            'http' => $res['http'],
            'raw'  => is_array($res['raw']) ? $res['raw'] : array(),
        );
    }

    /**
     * cURL workhorse. Performs a single HTTP request against the Bichon API
     * with the configured bearer token and returns a normalized result.
     *
     * Never throws on transport/HTTP errors — returns ok=false instead.
     *
     * @param string     $method HTTP verb
     * @param string     $path   path beginning with '/'
     * @param array|null $body   associative array to JSON-encode, or null
     * @return array ['ok'=>bool, 'msg'=>string, 'http'=>int, 'raw'=>array]
     */
    private function request(string $method, string $path, ?array $body = null): array {
        $endpoint = $this->url . $path;

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $endpoint);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

        $headers = array(
            'Authorization: Bearer ' . $this->token,
            'Accept: application/json',
        );

        if ($body !== null) {
            $payload = json_encode($body);
            if ($payload === false) {
                curl_close($ch);
                return array(
                    'ok'   => false,
                    'msg'  => 'Failed to JSON-encode request body',
                    'http' => 0,
                    'raw'  => array(),
                );
            }
            curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
            $headers[] = 'Content-Type: application/json';
        }

        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

        $response = curl_exec($ch);
        $http     = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $errno    = curl_errno($ch);
        $errstr   = curl_error($ch);
        curl_close($ch);

        if ($errno !== 0 || $response === false) {
            error_log('BichonClient: cURL error on ' . $method . ' ' . $path . ': ' . $errstr);
            return array(
                'ok'   => false,
                'msg'  => 'Connection failed: ' . $errstr,
                'http' => 0,
                'raw'  => array(),
            );
        }

        $decoded = null;
        if ($response !== '') {
            $decoded = json_decode($response, true);
        }
        $raw = is_array($decoded) ? $decoded : array();

        if ($http >= 200 && $http < 300) {
            return array(
                'ok'   => true,
                'msg'  => 'OK',
                'http' => $http,
                'raw'  => $raw,
            );
        }

        // Build a useful error message from Bichon's response body if it's JSON,
        // else fall back to "HTTP $code".
        $msg = 'HTTP ' . $http;
        if (is_array($decoded)) {
            foreach (array('error', 'message', 'detail', 'msg') as $k) {
                if (!empty($decoded[$k]) && is_string($decoded[$k])) {
                    $msg = trim($decoded[$k]);
                    break;
                }
            }
        } elseif (is_string($response) && $response !== '' && strlen($response) < 500) {
            $msg = 'HTTP ' . $http . ': ' . trim($response);
        }

        // Log non-2xx with a masked body dump (never log bearer token or password fields).
        $logged_body = '';
        if ($body !== null) {
            $masked = $this->maskSensitive($body);
            $logged_body = ' body=' . json_encode($masked);
        }
        error_log('BichonClient: ' . $method . ' ' . $path . ' -> HTTP ' . $http . ' msg=' . $msg . $logged_body);

        return array(
            'ok'   => false,
            'msg'  => $msg,
            'http' => $http,
            'raw'  => $raw,
        );
    }

    /**
     * Recursively redact sensitive keys from an array for safe logging.
     * Masks any key containing 'password', 'token', or 'secret'.
     *
     * @param array $data
     * @return array
     */
    private function maskSensitive(array $data): array {
        $out = array();
        foreach ($data as $k => $v) {
            if (is_string($k)) {
                $kl = strtolower($k);
                if (strpos($kl, 'password') !== false
                    || strpos($kl, 'secret')   !== false
                    || strpos($kl, 'token')    !== false
                    || $kl === 'authorization') {
                    $out[$k] = '***';
                    continue;
                }
            }
            if (is_array($v)) {
                $out[$k] = $this->maskSensitive($v);
            } else {
                $out[$k] = $v;
            }
        }
        return $out;
    }
}
