<?php

/**
 * Class for interacting with Creatia.
 */
class PhpCreatia {
    // Constants
    private const CREATIA_AUTH_ISSUE = 'auth/issue/';
    private const CREATIA_AUTH_REVOKE = 'auth/revoke/';
    private const CREATIA_ASSOCIATE_SUBNET = 'internal/organisation/{id}/network';
    private const CREATIA_EXTERNAL_IDENTITIES = 'internal/organisation';
    private const CREATIA_CREATE_ORGANISATION = 'internal/organisation';
    private const CREATIA_GET_PREFIXES = 'internal/organisation/{id}/network';
    private const CREATIA_UPDATE_ORGANISATION = 'internal/organisation/{id}';
    private const CREATIA_ADD_IDENTITY = 'internal/v2/organisation/{id}';
    private const CREATIA_UPDATE_MERCURY = 'internal/organisation/{id}/mercury';
    private const CREATIA_GET_RIGHT = 'internal/organisation/{id}/right';
    private const CREATIA_GRANT_RIGHT = 'internal/organisation/{id}/right';
    private const CREATIA_REVOKE_RIGHT = 'internal/organisation/{id}/right';
    private const CREATIA_GET_USER = 'internal/user';
    private const CREATIA_CREATE_USER = 'internal/organisation/{id}/user';

    private string $identifyToken;
    private string $secretToken;
    private string $endpoint;
    private ?string $bearerToken = null;
    private float $bearerExpires = 0;

    /**
     * Constructor
     */
    public function __construct(string $endpoint, string $identifyToken, string $secretToken) {
        $this->endpoint = "$endpoint/api/";
        $this->identifyToken = $identifyToken;
        $this->secretToken = $secretToken;
    }

    /**
     * Check if the current token is expired
     */
    private function isTokenExpired(): bool {
        return (microtime(true) > $this->bearerExpires);
    }

    /**
     * Authenticate with Creatia API and obtain a bearer token
     */
    public function authenticate(): bool {
        if ($this->isTokenExpired()) {
            $params = [
                'identifier' => $this->identifyToken,
                'token' => $this->secretToken
            ];
            
            $response = $this->post(self::CREATIA_AUTH_ISSUE, $params, false);
            
            if (isset($response["bearer"]) && isset($response["expiry"])) {
                $this->bearerToken = $response["bearer"];
                $this->bearerExpires = $response["expiry"];
                return true;
            }
            
            throw new Exception("Failed authentication");
        }
        
        return true;
    }

    /**
     * Revoke the current bearer token
     */
    public function revoke(): bool {
        $this->authenticate();

        if ($this->bearerToken !== null) {
            $this->post(self::CREATIA_AUTH_REVOKE, [], false);
            $this->bearerToken = null;
            $this->bearerExpires = 0;
            return true;
        }
        
        return false;
    }

    /**
     * Perform a GET request to the Creatia API
     */
    public function get(string $endpoint, array $params = []): array {
        $this->authenticate();
        
        $url = $this->endpoint . $endpoint;
        if (!empty($params)) {
            $url .= '?' . http_build_query($params);
        }
        
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_URL, $url);
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_HTTPHEADER, $this->getHeaders());
        curl_setopt($curl, CURLOPT_TIMEOUT, 15);
        
        $response = curl_exec($curl);
        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        
        if (curl_errno($curl)) {
            $error = curl_error($curl);
            curl_close($curl);
            throw new Exception("cURL Error: $error");
        }
        
        curl_close($curl);
        
        return $this->handleResponse($response, $httpCode);
    }

    /**
     * Perform a POST request to the Creatia API
     * 
     * @param string $endpoint API endpoint
     * @param array $payload Data to send in request body
     * @param bool $authenticate Whether to authenticate before making the request (default: true)
     * @param array $files Files to upload (optional)
     * @return array Response data as an associative array
     */
    public function post(string $endpoint, array $payload, bool $authenticate = true, array $files = []): array {
        if ($authenticate) {
            $this->authenticate();
        }
        
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_URL, $this->endpoint . $endpoint);
        curl_setopt($curl, CURLOPT_POST, 1);
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_TIMEOUT, 15);
        curl_setopt($curl, CURLOPT_HTTPHEADER, $this->getHeaders());
        
        if (empty($files)) {
            // Regular JSON post
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload));
            curl_setopt($curl, CURLOPT_HTTPHEADER, array_merge(
                $this->getHeaders(), 
                ['Content-Type: application/json']
            ));
        } else {
            // Multipart form with files
            $postFields = $payload;
            foreach ($files as $key => $filePath) {
                $postFields[$key] = curl_file_create($filePath);
            }
            curl_setopt($curl, CURLOPT_POSTFIELDS, $postFields);
            // Let cURL set the Content-Type header automatically for multipart/form-data
        }
        
        $response = curl_exec($curl);
        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        
        if (curl_errno($curl)) {
            $error = curl_error($curl);
            curl_close($curl);
            throw new Exception("cURL Error: $error");
        }
        
        curl_close($curl);

        return $this->handleResponse($response, $httpCode);
    }

    /**
     * Perform a PUT request to the Creatia API
     */
    public function put(string $endpoint, array $payload): array {
        $this->authenticate();
        
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_URL, $this->endpoint . $endpoint);
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PUT');
        curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload));
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_HTTPHEADER, array_merge(
            $this->getHeaders(), 
            ['Content-Type: application/json']
        ));
        curl_setopt($curl, CURLOPT_TIMEOUT, 15);
        
        $response = curl_exec($curl);
        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        
        if (curl_errno($curl)) {
            $error = curl_error($curl);
            curl_close($curl);
            throw new Exception("cURL Error: $error");
        }
        
        curl_close($curl);
        
        return $this->handleResponse($response, $httpCode);
    }

    /**
     * Perform a PATCH request to the Creatia API
     */
    public function patch(string $endpoint, array $payload): array {
        $this->authenticate();
        
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_URL, $this->endpoint . $endpoint);
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PATCH');
        curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload));
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_HTTPHEADER, array_merge(
            $this->getHeaders(), 
            ['Content-Type: application/json']
        ));
        curl_setopt($curl, CURLOPT_TIMEOUT, 15);
        
        $response = curl_exec($curl);
        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        
        if (curl_errno($curl)) {
            $error = curl_error($curl);
            curl_close($curl);
            throw new Exception("cURL Error: $error");
        }
        
        curl_close($curl);
        
        return $this->handleResponse($response, $httpCode);
    }

    /**
     * Perform a DELETE request to the Creatia API
     */
    public function delete(string $endpoint, array $payload = []): array {
        $this->authenticate();
        
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_URL, $this->endpoint . $endpoint);
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE');
        
        if (!empty($payload)) {
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload));
            curl_setopt($curl, CURLOPT_HTTPHEADER, array_merge(
                $this->getHeaders(), 
                ['Content-Type: application/json']
            ));
        } else {
            curl_setopt($curl, CURLOPT_HTTPHEADER, $this->getHeaders());
        }
        
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_TIMEOUT, 15);
        
        $response = curl_exec($curl);
        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        
        if (curl_errno($curl)) {
            $error = curl_error($curl);
            curl_close($curl);
            throw new Exception("cURL Error: $error");
        }
        
        curl_close($curl);
        
        return $this->handleResponse($response, $httpCode);
    }

    /**
     * Turn a raw cURL response into a decoded array.
     *
     * Any 2xx is accepted - some endpoints answer 201 or 204 rather than 200 -
     * and a body that is not JSON is reported rather than returned, since null
     * against an array return type surfaces as a TypeError instead of an error
     * the calling module can log.
     *
     * @param string|bool $response the raw response body
     * @param int $httpCode the HTTP status code that came back
     * @return array Response data as an associative array
     * @throws Exception on a non 2xx status or an undecodable body
     */
    private function handleResponse($response, int $httpCode): array {
        if ($httpCode < 200 || $httpCode >= 300) {
            throw new Exception("HTTP Error: $httpCode - $response");
        }

        if ($httpCode === 204 || $response === false || trim((string) $response) === '') {
            return [];
        }

        $decoded = json_decode($response, true);

        if (!is_array($decoded)) {
            throw new Exception("Malformed response from Creatia: HTTP $httpCode - $response");
        }

        return $decoded;
    }

    /**
     * Get HTTP headers for API requests
     */
    private function getHeaders(): array {
        $headers = ['User-Agent: phpCreatia'];
        
        if ($this->bearerToken !== null && !$this->isTokenExpired()) {
            $headers[] = 'Authorization: Bearer ' . $this->bearerToken;
        }
        
        return $headers;
    }

    // From this point on you have functions made specifically for just talking to Creatia without you needing to re-invent the wheel. Enjoy!

    /**
     * Associate a subnet with an organization in Creatia
     * 
     * @param string $creatiaId The organizational identifier for the organization
     * @param string $subnet The subnet in CIDR compliant format (e.g. "203.1.1.3/32")
     * @param string $name A user-friendly name for the prefix
     * @param bool $prefixOnly Whether subtenancy will grant control of this prefix itself
     * @param bool $childrenAllowed Whether subtenancy will grant control of the prefixes subnets
     * @param bool $override Whether typical prefix safety is respected
     * @param string $brand The brand responsible for this action (must be one of 'gsl', 'go-dedicated', 'streamline')
     * @return array Response data from the API
     */
    public function associateSubnet(string $creatiaId, string $subnet, string $name, bool $prefixOnly = true, bool $childrenAllowed = true, bool $override = false, string $brand = 'gsl'): array {
        $this->authenticate();
        
        $payload = [
            'subnet' => $subnet,
            'name' => $name,
            'prefix_only' => $prefixOnly,
            'children_allowed' => $childrenAllowed,
            'override' => $override,
            'brand' => $brand
        ];
        
        return $this->post(str_replace('{id}', $creatiaId, self::CREATIA_ASSOCIATE_SUBNET), $payload);
    }

    /**
     * Get external identities for organizations
     * 
     * @param string $application The application identifier (e.g., 'gsl-whmcs', 'gd-whmcs')
     * @param string|null $identity Optional specific identity to retrieve
     * @return array List of organizations with their Creatia identifiers
     */
    public function getExternalIdentities(string $application, ?string $identity = null): array {
        $params = ['application' => $application];
        
        if ($identity !== null) {
            $params['identity'] = $identity;
        }
        
        return $this->get(self::CREATIA_EXTERNAL_IDENTITIES, $params);
    }
    
    /**
     * Create a new organization in Creatia
     * 
     * @param string $name The name for the organization
     * @param array $identifiers Array of identifier objects with 'key' and 'identity' properties
     * @param string $subtenant The brand association ('gsl', 'go-dedicated', 'streamline')
     * @return array Response data from the API
     */
    public function createOrganisation(string $name, array $identifiers, string $subtenant = 'gsl'): array {
        $payload = [
            'name' => $name,
            'identifiers' => $identifiers,
            'subtenant' => $subtenant
        ];
        
        return $this->post(self::CREATIA_CREATE_ORGANISATION, $payload);
    }

    /**
     * Get subnets for a specific organization
     * 
     * @param string $creatiaId The Creatia ID of the organization
     * @param int $page Page number for pagination (optional)
     * @param int $perPage Number of items per page (optional)
     * @param string $sortField Field to sort by (optional)
     * @param string $sortOrder Sort direction ('asc' or 'desc', optional)
     * @return array List of subnets with their configuration details
     * @throws Exception If the request fails or validation fails
     */
    public function getSubnets(string $creatiaId, int $page = 1, int $perPage = 10, string $sortField = 'prefix', string $sortOrder = 'asc'): array {
        $params = [
            'page' => $page,
            'per_page' => $perPage,
            'sort_field' => $sortField,
            'sort_order' => $sortOrder
        ];
        
        return $this->get(str_replace("{id}", $creatiaId, self::CREATIA_GET_PREFIXES), $params);
    }

     /**
     * Update organization external identifiers
     * 
     * @param string $organisationId The ID of the organization to update
     * @param array $identifiers Array of identifier objects with 'key' and 'identity' properties
     * @return array Response data from the API
     * @throws Exception If the request fails
     */
    public function updateOrganisation(string $organisationId, array $identifiers): array {
        $payload = [
            'identifiers' => $identifiers
        ];
        
        return $this->put(str_replace("{id}", $organisationId, self::CREATIA_UPDATE_ORGANISATION), $payload);
    }

    /**
     * Add an external identity to an organisation, alongside any it already holds
     *
     * Goes to the v2 endpoint on purpose. updateOrganisation() uses v1, which keeps one
     * identity per key per organisation and overwrites it - linking a second client through
     * it would detach the first, and with it the CXID that client's tunnels are filed under.
     *
     * @param string $organisationId The ID of the organisation to add the identity to
     * @param string $key The identity key (e.g. 'gsl')
     * @param string $identity The identity to add under that key
     * @return array Response data from the API
     * @throws Exception If the request fails
     */
    public function addExternalIdentity(string $organisationId, string $key, string $identity): array {
        $payload = [
            'identifiers' => [
                [ 'key' => $key, 'identity' => $identity, 'mode' => 'add' ]
            ]
        ];

        return $this->put(str_replace("{id}", $organisationId, self::CREATIA_ADD_IDENTITY), $payload);
    }

    /**
     * Update mercury settings for an organization
     * 
     * @param string $organisationId The ID of the organization to update
     * @param int|float $bandwidth The aggregate bandwidth value to set
     * @return array Response data from the API
     * @throws Exception If the request fails
     */
    public function updateMercury(string $organisationId, int|float $bandwidth): array {
        $payload = [
            'bandwidth' => $bandwidth
        ];
        
        return $this->put(str_replace("{id}", $organisationId, self::CREATIA_UPDATE_MERCURY), $payload);
    }
    
    /**
     * Get the organisations known product rights
     * 
     * @param string $organisationId The ID of the organization to update
     * @return array Response data from the API
     * @throws Exception If the request fails
     */
    public function getProductRight(string $organisationId): array {        
        return $this->get(str_replace("{id}", $organisationId, self::CREATIA_GET_RIGHT), []);
    }

    /**
     * Add a product right to an organization
     * 
     * @param string $organisationId The ID of the organization to update
     * @param string $right The name of the product right to add
     * @return array Response data from the API
     * @throws Exception If the request fails
     */
    public function addProductRight(string $organisationId, string $right): array {
        $payload = [
            'right' => $right
        ];
        
        return $this->post(str_replace("{id}", $organisationId, self::CREATIA_GRANT_RIGHT), $payload);
    }
    
    /**
     * Remove a product right from an organization
     * 
     * @param string $organisationId The ID of the organization to update
     * @param string $right The name of the product right to remove
     * @return array Response data from the API
     * @throws Exception If the request fails
     */
    public function removeProductRight(string $organisationId, string $right): array {
        $payload = [
            'right' => $right
        ];
        
        return $this->delete(str_replace("{id}", $organisationId, self::CREATIA_REVOKE_RIGHT), $payload);
    }

    /**
     * Get a known user and their corresponding organisation
     * 
     * @param string $email The email address for the new user
     * @return array Response data from the API containing the user email and organisation_id
     * @throws Exception If the request fails
     */
    public function getUser(string $email): array {        
        return $this->get(self::CREATIA_GET_USER, [ 'email' => $email ]);
    }

    /**
     * Create a new user for an organization
     * 
     * @param string $organisationId The ID of the organization to create the user for
     * @param string $email The email address for the new user
     * @param string $name The name for the new user
     * @return array Response data from the API containing the user ID
     * @throws Exception If the request fails
     */
    public function createUser(string $organisationId, string $email, string $name): array {
        $payload = [
            'email' => $email,
            'name' => $name
        ];
        
        return $this->post(str_replace("{id}", $organisationId, self::CREATIA_CREATE_USER), $payload);
    }
}