<?php

declare(strict_types=1);

namespace Dcblogdev\Xero;

use Dcblogdev\Xero\Actions\StoreTokenAction;
use Dcblogdev\Xero\Actions\tokenExpiredAction;
use Dcblogdev\Xero\Models\XeroToken;
use Dcblogdev\Xero\Resources\Accounts;
use Dcblogdev\Xero\Resources\Contacts;
use Dcblogdev\Xero\Resources\CreditNotes;
use Dcblogdev\Xero\Resources\Invoices;
use Dcblogdev\Xero\Resources\Webhooks;
use Dcblogdev\Xero\Traits\XeroHelpersTrait;
use Exception;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use RuntimeException;

/**
 * @method static array get (string $endpoint, array $params = [])
 * @method static array put (string $endpoint, array $params = [])
 * @method static array post (string $endpoint, array $params = [])
 * @method static array patch (string $endpoint, array $params = [])
 * @method static array delete (string $endpoint, array $params = [])
 */
class Xero
{
    use XeroHelpersTrait;

    protected static string $baseUrl = 'https://api.xero.com/api.xro/2.0/';

    protected static string $authorizeUrl = 'https://login.xero.com/identity/connect/authorize';

    protected static string $connectionUrl = 'https://api.xero.com/connections';

    protected static string $tokenUrl = 'https://identity.xero.com/connect/token';

    protected static string $revokeUrl = 'https://identity.xero.com/connect/revocation';

    protected string $tenant_id = '';

    protected bool $returnFullResponse = false;

    /**
     * __call catches all requests when no found method is requested
     *
     * @param  string  $function  - the verb to execute
     * @param  array  $args  - array of arguments
     * @return array
     *
     * @throws Exception
     */
    public function __call(string $function, array $args)
    {
        $options = ['get', 'post', 'patch', 'put', 'delete'];
        $path = $args[0] ?? '';
        $data = $args[1] ?? [];
        $raw = $args[2] ?? false;
        $accept = $args[3] ?? 'application/json';
        $headers = $args[4] ?? []; // Add a new line for custom headers

        if (in_array($function, $options)) {
            return $this->guzzle($function, $path, $data, $raw, $accept, $headers);
        }
        // request verb is not in the $options array
        throw new RuntimeException($function.' is not a valid HTTP Verb');
    }

    public function setTenantId(string $tenant_id): void
    {
        $this->tenant_id = $tenant_id;
    }

    public function withFullResponse(): static
    {
        $this->returnFullResponse = true;

        return $this;
    }

    public function accounts(): Accounts
    {
        return new Accounts;
    }

    public function contacts(): Contacts
    {
        $resource = new Contacts;
        $resource->setTenantId($this->tenant_id);

        return $resource;
    }

    public function creditnotes(): CreditNotes
    {
        $resource = new CreditNotes;
        $resource->setTenantId($this->tenant_id);

        return $resource;
    }

    public function invoices(): Invoices
    {
        $resource = new Invoices;
        $resource->setTenantId($this->tenant_id);

        return $resource;
    }

    public function webhooks(): Webhooks
    {
        $resource = new Webhooks;
        $resource->setTenantId($this->tenant_id);

        return $resource;
    }

    public function isTokenValid(): bool
    {
        $token = $this->getTokenData();

        if ($token === null) {
            return false;
        }

        $now = now()->addMinutes(5);

        if ($token->expires < $now) {
            return false;
        }

        return true;
    }

    public function isConnected(): bool
    {
        return ! ($this->getTokenData() === null);
    }

    public function disconnect(): void
    {
        $token = $this->getTokenData();

        if ($token === null) {
            return;
        }

        try {
            Http::withHeaders([
                'authorization' => 'Basic '.base64_encode(config('xero.clientId').':'.config('xero.clientSecret')),
            ])
                ->asForm()
                ->post(self::$revokeUrl, [
                    'token' => $token->refresh_token,
                ]);
        } catch (Exception) {
            // Ignore revocation errors — local token will still be deleted below.
        }

        $token->delete();
    }

    /**
     * Make a connection or return a token where it's valid
     *
     *
     * @throws Exception
     */
    public function connect(): RedirectResponse|Application|Redirector
    {
        // when no code param redirect to Microsoft
        if (request()->has('code')) {
            // With the authorization code, we can retrieve access tokens and other data.
            try {
                $params = [
                    'grant_type' => 'authorization_code',
                    'code' => request('code'),
                    'redirect_uri' => config('xero.redirectUri'),
                ];

                $result = $this->sendPost(self::$tokenUrl, $params);

                try {
                    $response = Http::withHeaders([
                        'Authorization' => 'Bearer '.$result['access_token'],
                    ])
                        ->acceptJson()
                        ->get(self::$connectionUrl)
                        ->throw()
                        ->json();

                    foreach ($response as $tenant) {
                        $tenantData = [
                            'auth_event_id' => $tenant['authEventId'],
                            'tenant_id' => $tenant['tenantId'],
                            'tenant_type' => $tenant['tenantType'],
                            'tenant_name' => $tenant['tenantName'],
                            'created_date_utc' => $tenant['createdDateUtc'],
                            'updated_date_utc' => $tenant['updatedDateUtc'],
                        ];

                        app(StoreTokenAction::class)($result, $tenantData, $tenant['tenantId']);
                    }
                } catch (Exception $e) {
                    throw new Exception('Error getting tenant: '.$e->getMessage());
                }

                return redirect(config('xero.landingUri'));
            } catch (Exception $e) {
                throw new Exception($e->getMessage());
            }
        }

        $url = self::$authorizeUrl.'?'.http_build_query([
            'response_type' => 'code',
            'client_id' => config('xero.clientId'),
            'redirect_uri' => config('xero.redirectUri'),
            'scope' => config('xero.scopes'),
        ]);

        return redirect()->away($url);
    }

    public function getTokenData(): ?XeroToken
    {
        if ($this->tenant_id) {
            $token = XeroToken::where('tenant_id', '=', $this->tenant_id)->first();
        } else {
            $token = XeroToken::first();
        }

        if ($token && config('xero.encrypt')) {
            try {
                $access_token = Crypt::decryptString($token->access_token);
            } catch (DecryptException $e) {
                $access_token = $token->access_token;
            }

            // Split them as a refresh token may not exist...
            try {
                $refresh_token = Crypt::decryptString($token->refresh_token);
            } catch (DecryptException $e) {
                $refresh_token = $token->refresh_token;
            }

            $token->access_token = $access_token;
            $token->refresh_token = $refresh_token;
        }

        return $token;
    }

    /**
     * @throws Exception
     */
    public function getAccessToken(bool $redirectWhenNotConnected = true): string
    {
        /* @var XeroToken $token */
        $token = $this->getTokenData();

        $this->redirectIfNoToken($token, $redirectWhenNotConnected);

        $now = now()->addMinutes(5);

        if ($token->expires < $now) {
            return $this->renewExpiringToken($token);
        }

        return $token->access_token;
    }

    /**
     * @throws Exception
     */
    public function renewExpiringToken(XeroToken $token): string
    {
        $params = [
            'grant_type' => 'refresh_token',
            'refresh_token' => $token->refresh_token,
            'redirect_uri' => config('xero.redirectUri'),
        ];

        $result = $this->sendPost(self::$tokenUrl, $params);

        app(tokenExpiredAction::class)($result, $token);
        app(StoreTokenAction::class)($result, ['tenant_id' => $token->tenant_id], $this->tenant_id);

        return $result['access_token'];
    }

    public function getTenantId(): string
    {
        $token = $this->getTokenData();

        $this->redirectIfNoToken($token);

        return $token->tenant_id;
    }

    public function getTenantName(): ?string
    {
        // use id if passed otherwise use logged-in user
        $token = $this->getTokenData();

        $this->redirectIfNoToken($token);

        // Token is still valid, just return it
        return $token->tenant_name;
    }

    /**
     * @throws Exception
     */
    protected static function sendPost(string $url, array $params): array
    {
        try {
            $response = Http::withHeaders([
                'authorization' => 'Basic '.base64_encode(config('xero.clientId').':'.config('xero.clientSecret')),
            ])
                ->asForm()
                ->acceptJson()
                ->post($url, $params);

            return $response->json();

        } catch (Exception $e) {
            throw new Exception($e->getMessage());
        }
    }

    protected function redirectIfNoToken(XeroToken $token, bool $redirectWhenNotConnected = true): RedirectResponse|bool
    {
        // Check if tokens exist otherwise run the oauth request
        if (! $this->isConnected() && $redirectWhenNotConnected === true) {
            return redirect()->away(config('xero.redirectUri'));
        }

        return false;
    }

    /**
     * run Guzzle to process the requested url
     *
     * @throws Exception
     */
    protected function guzzle(string $type, string $request, array $data = [], bool $raw = false, string $accept = 'application/json', array $headers = []): array
    {
        if ($data === []) {
            $data = null;
        }

        try {
            $response = Http::retry([200, 500, 1000], fn ($exception) => $exception instanceof ConnectionException || ($exception instanceof RequestException && $exception->response->serverError()))
                ->withToken($this->getAccessToken())
                ->withHeaders(array_merge(['Xero-tenant-id' => $this->getTenantId()], $headers))
                ->accept($accept)
                ->$type(self::$baseUrl.$request, $data)
                ->throw();

            return [
                'body' => $raw ? $response->body() : $response->json(),
                'headers' => $response->getHeaders(),
            ];
        } catch (RequestException $e) {
            $response = json_decode($e->response->body());
            throw new Exception($response->Detail ?? "Type: $response?->Type Message: $response?->Message Error Number: $response?->ErrorNumber");
        } catch (Exception $e) {
            throw new Exception($e->getMessage());
        }
    }
}
