<?php

namespace classes;

require_once WD . '/modules/n8n/n8n_c.php';

use Exception;
use interfaces\n8n_i;
use n8n\n8n_c;
use stdClass;

class n8n implements n8n_i
{
    private const WORKFLOW_READ_ONLY_FIELDS = [
        'id',
        'active',
        'createdAt',
        'updatedAt',
        'tags',
        'shared',
        'activeVersion',
    ];

    public n8n_c $config;

    public function __construct()
    {
        $this->config = new n8n_c();
    }

    /**
     * @throws Exception
     */
    public function requireModuleEnabled(): void
    {
        if (!$this->config->enabled->isTrue()) {
            throw new Exception('The n8n module is not enabled.');
        }
    }

    /**
     * @throws Exception
     */
    public function listWorkflows(array $filters = []): object
    {
        return $this->sendApiRequest('GET', '/workflows', $this->filterAllowed($filters, [
            'active',
            'tags',
            'name',
            'projectId',
            'excludePinnedData',
            'limit',
            'cursor',
        ]));
    }

    /**
     * @throws Exception
     */
    public function getWorkflow(string $id, bool $excludePinnedData = false): object
    {
        $this->requireValidIdentifier($id, 'workflow id');

        $query = [];
        if ($excludePinnedData) {
            $query['excludePinnedData'] = true;
        }

        return $this->sendApiRequest('GET', '/workflows/' . rawurlencode($id), $query);
    }

    /**
     * @throws Exception
     */
    public function createWorkflow(object $workflow): object
    {
        return $this->sendApiRequest('POST', '/workflows', [], $this->sanitizeWorkflowPayload($workflow));
    }

    /**
     * @throws Exception
     */
    public function updateWorkflow(string $id, object $changes): object
    {
        $this->requireValidIdentifier($id, 'workflow id');

        $existing = $this->getWorkflow($id);
        $merged = $this->mergeWorkflowPayload($existing, $changes);

        return $this->sendApiRequest('PUT', '/workflows/' . rawurlencode($id), [], $merged);
    }

    /**
     * @throws Exception
     */
    public function publishWorkflow(string $id, ?object $options = null): object
    {
        $this->requireValidIdentifier($id, 'workflow id');

        return $this->sendApiRequest(
            'POST',
            '/workflows/' . rawurlencode($id) . '/activate',
            [],
            $options !== null ? $this->filterPublishOptions($options) : null
        );
    }

    /**
     * @throws Exception
     */
    public function deactivateWorkflow(string $id): object
    {
        $this->requireValidIdentifier($id, 'workflow id');

        return $this->sendApiRequest('POST', '/workflows/' . rawurlencode($id) . '/deactivate');
    }

    /**
     * @throws Exception
     */
    public function runWebhook(string $webhookTarget, mixed $payload = null, string $method = 'POST', array $query = []): object
    {
        $this->requireModuleEnabled();

        $url = $this->resolveWebhookUrl($webhookTarget);
        $normalizedMethod = $this->normalizeMethod($method);

        return $this->sendWebhookRequest($normalizedMethod, $url, $query, $payload);
    }

    /**
     * @throws Exception
     */
    public function listExecutions(array $filters = []): object
    {
        return $this->sendApiRequest('GET', '/executions', $this->filterAllowed($filters, [
            'includeData',
            'status',
            'workflowId',
            'projectId',
            'limit',
            'cursor',
        ]));
    }

    /**
     * @throws Exception
     */
    public function getExecution(int $id, bool $includeData = false): object
    {
        $this->requirePositiveInteger($id, 'execution id');

        $query = [];
        if ($includeData) {
            $query['includeData'] = true;
        }

        return $this->sendApiRequest('GET', '/executions/' . $id, $query);
    }

    /**
     * @throws Exception
     */
    public function retryExecution(int $id, bool $loadWorkflow = false): object
    {
        $this->requirePositiveInteger($id, 'execution id');

        $payload = null;
        if ($loadWorkflow) {
            $payload = (object)['loadWorkflow' => true];
        }

        return $this->sendApiRequest('POST', '/executions/' . $id . '/retry', [], $payload);
    }

    /**
     * @throws Exception
     */
    public function stopExecution(int $id): object
    {
        $this->requirePositiveInteger($id, 'execution id');

        return $this->sendApiRequest('POST', '/executions/' . $id . '/stop');
    }

    /**
     * @throws Exception
     */
    private function sendApiRequest(string $method, string $path, array $query = [], ?object $body = null): object
    {
        $this->requireModuleEnabled();
        $this->requireConfiguredApiUrl();
        $this->requireConfiguredApiKey();

        $url = $this->buildUrl($this->config->api_url->getVariableValue(), $path, $query);
        $headers = [
            'Accept: application/json',
            'X-N8N-API-KEY: ' . trim((string)$this->config->api_key->getVariableValue()),
        ];

        return $this->executeJsonRequest($method, $url, $headers, $body);
    }

    /**
     * @throws Exception
     */
    private function sendWebhookRequest(string $method, string $url, array $query = [], mixed $body = null): object
    {
        $headers = ['Accept: application/json'];
        $payload = null;

        if ($body !== null) {
            $headers[] = 'Content-Type: application/json';
            $payload = json_encode($body, JSON_UNESCAPED_UNICODE);
            if ($payload === false) {
                throw new Exception('Unable to encode n8n webhook payload as JSON.');
            }
        }

        $response = $this->executeRequest($method, $this->buildUrl($url, '', $query), $headers, $payload);
        if ($response['status'] >= 400) {
            throw new Exception($this->extractErrorMessage($response['body'], $response['status'], 'Webhook request failed'));
        }

        $decoded = json_decode($response['body']);
        if (json_last_error() === JSON_ERROR_NONE) {
            if (is_object($decoded)) {
                $decoded->status_code = $response['status'];
                return $decoded;
            }

            return (object)[
                'status_code' => $response['status'],
                'data' => $decoded,
            ];
        }

        return (object)[
            'status_code' => $response['status'],
            'body' => $response['body'],
        ];
    }

    /**
     * @throws Exception
     */
    private function executeJsonRequest(string $method, string $url, array $headers, ?object $body = null): object
    {
        $payload = null;
        if ($body !== null) {
            $headers[] = 'Content-Type: application/json';
            $payload = json_encode($body, JSON_UNESCAPED_UNICODE);
            if ($payload === false) {
                throw new Exception('Unable to encode n8n request body as JSON.');
            }
        }

        $response = $this->executeRequest($method, $url, $headers, $payload);
        $decoded = json_decode($response['body']);

        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new Exception('Invalid JSON response from n8n (HTTP ' . $response['status'] . ').');
        }

        if ($response['status'] >= 400) {
            throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'n8n API request failed'));
        }

        if (is_object($decoded)) {
            return $decoded;
        }

        return (object)[
            'data' => $decoded,
        ];
    }

    /**
     * @throws Exception
     */
    private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array
    {
        $curl = curl_init();
        curl_setopt_array($curl, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => $headers,
        ]);

        if ($body !== null) {
            curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
        }

        $responseBody = curl_exec($curl);
        $statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $error = curl_error($curl);
        curl_close($curl);

        if ($error !== '') {
            throw new Exception('cURL request to n8n failed: ' . $error);
        }

        if ($responseBody === false) {
            throw new Exception('n8n request returned an empty response.');
        }

        return [
            'status' => $statusCode,
            'body' => (string)$responseBody,
        ];
    }

    private function buildUrl(string $baseUrl, string $path = '', array $query = []): string
    {
        $url = rtrim(trim($baseUrl), '/');
        if ($path !== '') {
            $url .= '/' . ltrim($path, '/');
        }

        $query = array_filter($query, static function (mixed $value): bool {
            return $value !== null && $value !== '';
        });

        if ($query !== []) {
            $url .= '?' . http_build_query($query);
        }

        return $url;
    }

    /**
     * @throws Exception
     */
    private function resolveWebhookUrl(string $target): string
    {
        $target = trim($target);
        if ($target === '') {
            throw new Exception('Webhook target must not be empty.');
        }

        $baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue());
        if ($baseUrl === '') {
            throw new Exception('n8n webhook base URL is not configured.');
        }

        if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
            if (!$this->isAllowedWebhookAbsoluteUrl($target, $baseUrl)) {
                throw new Exception('Webhook URL must use the configured n8n webhook host.');
            }

            return $target;
        }

        return rtrim($baseUrl, '/') . '/' . ltrim($target, '/');
    }

    private function isAllowedWebhookAbsoluteUrl(string $targetUrl, string $baseUrl): bool
    {
        $targetParts = parse_url($targetUrl);
        $baseParts = parse_url($baseUrl);

        if (!is_array($targetParts) || !is_array($baseParts)) {
            return false;
        }

        $targetHost = strtolower((string)($targetParts['host'] ?? ''));
        $baseHost = strtolower((string)($baseParts['host'] ?? ''));
        if ($targetHost === '' || $baseHost === '' || $targetHost !== $baseHost) {
            return false;
        }

        $targetScheme = strtolower((string)($targetParts['scheme'] ?? ''));
        $baseScheme = strtolower((string)($baseParts['scheme'] ?? ''));
        if ($targetScheme === '' || $baseScheme === '' || $targetScheme !== $baseScheme) {
            return false;
        }

        $targetPort = (int)($targetParts['port'] ?? ($targetScheme === 'https' ? 443 : 80));
        $basePort = (int)($baseParts['port'] ?? ($baseScheme === 'https' ? 443 : 80));

        return $targetPort === $basePort;
    }

    /**
     * @throws Exception
     */
    private function sanitizeWorkflowPayload(object $workflow): object
    {
        $payload = $this->cloneObject($workflow);

        foreach (self::WORKFLOW_READ_ONLY_FIELDS as $field) {
            if (property_exists($payload, $field)) {
                unset($payload->{$field});
            }
        }

        if (!property_exists($payload, 'name') || !is_string($payload->name) || trim($payload->name) === '') {
            throw new Exception('Workflow name is required.');
        }

        if (!property_exists($payload, 'nodes') || !is_array($payload->nodes)) {
            throw new Exception('Workflow nodes are required and must be an array.');
        }

        if (!property_exists($payload, 'connections')) {
            throw new Exception('Workflow connections are required.');
        }

        if (!property_exists($payload, 'settings') || $payload->settings === null) {
            $payload->settings = new stdClass();
        }

        $payload->connections = $this->normalizeObjectValue($payload->connections, 'connections');
        $payload->settings = $this->normalizeObjectValue($payload->settings, 'settings');

        if (property_exists($payload, 'staticData') && is_array($payload->staticData) && !array_is_list($payload->staticData)) {
            $payload->staticData = $this->arrayToObject($payload->staticData);
        }

        return $payload;
    }

    private function mergeWorkflowPayload(object $existing, object $changes): object
    {
        $merged = $this->cloneObject($existing);

        foreach (get_object_vars($changes) as $key => $value) {
            if (in_array($key, self::WORKFLOW_READ_ONLY_FIELDS, true)) {
                continue;
            }

            if (property_exists($merged, $key) && is_object($merged->{$key}) && is_object($value)) {
                $merged->{$key} = $this->mergeObjects($merged->{$key}, $value);
                continue;
            }

            $merged->{$key} = $value;
        }

        return $this->sanitizeWorkflowPayload($merged);
    }

    private function mergeObjects(object $base, object $changes): object
    {
        foreach (get_object_vars($changes) as $key => $value) {
            if (property_exists($base, $key) && is_object($base->{$key}) && is_object($value)) {
                $base->{$key} = $this->mergeObjects($base->{$key}, $value);
                continue;
            }

            $base->{$key} = $value;
        }

        return $base;
    }

    /**
     * @throws Exception
     */
    private function normalizeObjectValue(mixed $value, string $field): object
    {
        if ($value instanceof stdClass || is_object($value)) {
            return $value;
        }

        if (is_array($value) && !array_is_list($value)) {
            return $this->arrayToObject($value);
        }

        if ($value === [] && $field === 'settings') {
            return new stdClass();
        }

        throw new Exception('Workflow ' . $field . ' must be an object.');
    }

    private function arrayToObject(array $value): object
    {
        $object = new stdClass();

        foreach ($value as $key => $item) {
            $object->{$key} = $this->normalizeMixedValue($item);
        }

        return $object;
    }

    private function normalizeMixedValue(mixed $value): mixed
    {
        if (!is_array($value)) {
            return $value;
        }

        if (array_is_list($value)) {
            return array_map(fn (mixed $item): mixed => $this->normalizeMixedValue($item), $value);
        }

        return $this->arrayToObject($value);
    }

    private function cloneObject(object $value): object
    {
        $encoded = json_encode($value, JSON_UNESCAPED_UNICODE);
        if ($encoded === false) {
            return clone $value;
        }

        $decoded = json_decode($encoded);
        return is_object($decoded) ? $decoded : clone $value;
    }

    private function filterPublishOptions(object $options): object
    {
        $filtered = new stdClass();

        foreach (['versionId', 'name', 'description'] as $field) {
            if (property_exists($options, $field) && $options->{$field} !== null && $options->{$field} !== '') {
                $filtered->{$field} = $options->{$field};
            }
        }

        return $filtered;
    }

    private function filterAllowed(array $filters, array $allowedKeys): array
    {
        $allowed = array_flip($allowedKeys);
        $filtered = [];

        foreach ($filters as $key => $value) {
            if (isset($allowed[$key])) {
                $filtered[$key] = $value;
            }
        }

        return $filtered;
    }

    /**
     * @throws Exception
     */
    private function requireConfiguredApiUrl(): void
    {
        $url = trim((string)$this->config->api_url->getVariableValue());
        if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) {
            throw new Exception('Invalid n8n API URL configured.');
        }
    }

    /**
     * @throws Exception
     */
    private function requireConfiguredApiKey(): void
    {
        if (trim((string)$this->config->api_key->getVariableValue()) === '') {
            throw new Exception('Invalid n8n API key configured.');
        }
    }

    private function normalizeUrlForValidation(string $url): string
    {
        if (preg_match('#^https?://#i', $url)) {
            return $url;
        }

        return 'http://' . ltrim($url, '/');
    }

    /**
     * @throws Exception
     */
    private function requireValidIdentifier(string $value, string $label): void
    {
        if (trim($value) === '') {
            throw new Exception('Invalid ' . $label . '.');
        }
    }

    /**
     * @throws Exception
     */
    private function requirePositiveInteger(int $value, string $label): void
    {
        if ($value <= 0) {
            throw new Exception('Invalid ' . $label . '.');
        }
    }

    private function normalizeMethod(string $method): string
    {
        $normalized = strtoupper(trim($method));
        if (!in_array($normalized, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) {
            return 'POST';
        }

        return $normalized;
    }

    private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string
    {
        if (is_object($decoded)) {
            if (isset($decoded->message) && is_string($decoded->message)) {
                return $decoded->message;
            }
            if (isset($decoded->error) && is_string($decoded->error)) {
                return $decoded->error;
            }
        }

        if (is_string($decoded) && trim($decoded) !== '') {
            return $decoded;
        }

        return $fallback . ' (HTTP ' . $statusCode . ').';
    }
}
