<?php

/*
 * Transaction.php
 * Copyright (c) 2025 james@firefly-iii.org
 *
 * This file is part of Firefly III (https://github.com/firefly-iii).
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace App\Services\Nordigen\Model;

use App\Rules\Iban;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
use DateTimeInterface;
use Illuminate\Support\Facades\Log;
use JsonException;
use Ramsey\Uuid\Uuid;
use Validator;

/**
 * Class Transaction
 */
final class Transaction
{
    public string $accountIdentifier;
    public string $additionalInformation;
    public string $additionalInformationStructured;
    public Balance $balanceAfterTransaction;
    public string $bankTransactionCode;
    public ?Carbon $bookingDate = null;
    public string $checkId;
    public string $creditorAccountBban;
    public string $creditorAccountCurrency;
    public string $creditorAccountIban; // is an array (see https://github.com/firefly-iii/firefly-iii/issues/5286)
    public string $creditorAgent;
    public string $creditorId;
    public string $creditorName;
    public string $currencyCode;
    public array $currencyExchange;
    public string $debtorAccountBban;
    public string $debtorAccountCurrency;
    public string $debtorAccountIban;
    public string $debtorAgent;
    public string $debtorName;
    public string $endToEndId;
    public string $entryReference;
    public string $key;
    public string $mandateId;
    public string $merchantCategoryCode;
    public string $proprietaryBank;

    // debtorAccount is an array, but is saved as strings
    // iban, currency
    public string $purposeCode;
    public string $remittanceInformationStructured;
    public array $remittanceInformationStructuredArray;

    // creditorAccount is an array, but saved as strings:
    // iban, currency
    public string $remittanceInformationUnstructured;
    public array $remittanceInformationUnstructuredArray;
    public array $tags;

    // transactionAmount is an array, but is saved as strings
    // amount, currency
    public string $transactionAmount;
    public string $transactionId;

    // my own custom fields
    public string $ultimateCreditor;

    // undocumented fields
    public string $ultimateDebtor;

    // new fields
    public ?Carbon $valueDate   = null;

    /**
     * Creates a transaction from a downloaded array.
     *
     * @param mixed $array
     */
    public static function fromArray($array): self
    {
        Log::debug('GoCardless transaction from array', $array);
        $object                                         = new self();
        $object->tags                                   = [];
        $object->additionalInformation                  = trim($array['additionalInformation'] ?? '');
        $object->additionalInformationStructured        = trim($array['additionalInformationStructured'] ?? '');
        $object->bankTransactionCode                    = trim($array['bankTransactionCode'] ?? '');
        $object->bookingDate                            = array_key_exists('bookingDate', $array)
            ? Carbon::createFromFormat('!Y-m-d', $array['bookingDate'], config('app.timezone'))
            : null;

        // overrule with "bookingDateTime" if present:
        if (array_key_exists('bookingDateTime', $array)) {
            try {
                $object->bookingDate = Carbon::parse($array['bookingDateTime'], config('app.timezone'));
            } catch (InvalidFormatException $e) {
                Log::error(sprintf('Could not parse bookingDateTime "%s": %s', $array['bookingDateTime'], $e->getMessage()));
            }
        }

        $object->key                                    = trim($array['key'] ?? '');
        $object->checkId                                = trim($array['checkId'] ?? '');
        $object->creditorAgent                          = trim($array['creditorAgent'] ?? '');
        $object->creditorId                             = trim($array['creditorId'] ?? '');
        $object->creditorName                           = trim($array['creditorName'] ?? '');
        $object->currencyExchange                       = $array['currencyExchange'] ?? [];
        $object->debtorAgent                            = trim($array['debtorAgent'] ?? '');
        $object->debtorName                             = trim($array['debtorName'] ?? '');
        $object->entryReference                         = trim($array['entryReference'] ?? '');
        $object->mandateId                              = trim($array['mandateId'] ?? '');
        $object->proprietaryBank                        = trim($array['proprietaryBank'] ?? '');
        $object->merchantCategoryCode                   = trim($array['merchantCategoryCode'] ?? '');
        $object->purposeCode                            = trim($array['purposeCode'] ?? '');
        $object->remittanceInformationStructured        = trim($array['remittanceInformationStructured'] ?? '');
        $object->remittanceInformationStructuredArray   = $array['remittanceInformationStructuredArray'] ?? [];
        $object->remittanceInformationUnstructured      = trim($array['remittanceInformationUnstructured'] ?? '');
        $object->remittanceInformationUnstructuredArray = $array['remittanceInformationUnstructuredArray'] ?? [];
        $object->transactionId                          = trim($array['transactionId'] ?? '');
        $object->ultimateCreditor                       = trim($array['ultimateCreditor'] ?? '');
        $object->ultimateDebtor                         = trim($array['ultimateDebtor'] ?? '');
        $object->valueDate                              = array_key_exists('valueDate', $array)
            ? Carbon::createFromFormat('!Y-m-d', $array['valueDate'], config('app.timezone'))
            : null;

        // undocumented values
        $object->endToEndId                             = trim($array['endToEndId'] ?? ''); // from Rabobank NL

        // overrule transaction id when empty using the internal ID:
        // 2025-09-07: switch to using internal transaction ID, never "transactionId".
        $object->transactionId                          = trim($array['internalTransactionId'] ?? '');

        // models:
        if (array_key_exists('balanceAfterTransaction', $array) && is_array($array['balanceAfterTransaction'])) {
            $object->balanceAfterTransaction = Balance::createFromArray($array['balanceAfterTransaction']);
        }
        if (array_key_exists('balanceAfterTransaction', $array) && !is_array($array['balanceAfterTransaction'])) {
            Log::warning(sprintf('balanceAfterTransaction is not an array: %s', $array['balanceAfterTransaction']));
            $object->balanceAfterTransaction = Balance::createFromArray([]);
        }
        if (!array_key_exists('balanceAfterTransaction', $array)) {
            $object->balanceAfterTransaction = Balance::createFromArray([]);
        }

        // add "pending" or "booked" if it exists.
        $key                                            = (string) $array['key'];
        if ('' !== $key) {
            $object->tags[] = $key;
        }

        // add merchant category code, if it exists:
        $merchantCode                                   = (string) ($array['merchant_category_code'] ?? '');
        if ('' !== $merchantCode) {
            $object->tags[] = $merchantCode;
        }

        // array values:
        $object->creditorAccountIban                    = trim($array['creditorAccount']['iban'] ?? '');
        $object->creditorAccountBban                    = trim($array['creditorAccount']['bban'] ?? '');
        $object->creditorAccountCurrency                = trim($array['creditorAccount']['currency'] ?? '');

        $object->debtorAccountIban                      = trim($array['debtorAccount']['iban'] ?? '');
        $object->debtorAccountBban                      = trim($array['debtorAccount']['bban'] ?? '');
        $object->debtorAccountCurrency                  = trim($array['debtorAccount']['currency'] ?? '');

        $object->transactionAmount                      = trim($array['transactionAmount']['amount'] ?? '');
        $object->currencyCode                           = trim($array['transactionAmount']['currency'] ?? '');

        // other fields:
        $object->accountIdentifier                      = $array['account_id'] ?? '';

        // generate transactionID if empty:
        if ('' === $object->transactionId) {
            $hash                  = hash('sha256', (string) microtime());

            try {
                $hash = hash('sha256', json_encode($array, JSON_THROW_ON_ERROR));
                Log::warning('Generated random transaction ID from array!');
            } catch (JsonException $e) {
                Log::error(sprintf('Could not parse array into JSON: %s', $e->getMessage()));
            }
            $object->transactionId = sprintf('ff3-%s', Uuid::uuid5(config('importer.namespace'), $hash));
        }
        if ('' !== $object->transactionId) {
            Log::debug('Transaction has correct transaction ID.');
        }
        Log::debug(sprintf('Downloaded transaction with ID "%s"', $object->transactionId));

        return $object;
    }

    /**
     * @return static
     */
    public static function fromLocalArray(array $array): self
    {
        $object                                         = new self();

        $object->tags                                   = [];
        $object->additionalInformation                  = $array['additional_information'];
        $object->additionalInformationStructured        = $array['additional_information_structured'];
        $object->balanceAfterTransaction                = Balance::fromLocalArray($array['balance_after_transaction']);
        $object->bankTransactionCode                    = $array['bank_transaction_code'];
        $object->bookingDate                            = Carbon::createFromFormat(DateTimeInterface::W3C, $array['booking_date']);
        $object->checkId                                = $array['check_id'];
        $object->creditorAgent                          = $array['creditor_agent'];
        $object->creditorId                             = $array['creditor_id'];
        $object->creditorName                           = $array['creditor_name'];
        $object->currencyExchange                       = $array['currency_exchange'];
        $object->merchantCategoryCode                   = $array['merchant_category_code'];
        $object->debtorAgent                            = $array['debtor_agent'];
        $object->debtorName                             = $array['debtor_name'];
        $object->entryReference                         = $array['entry_reference'];
        $object->key                                    = $array['key'];
        $object->mandateId                              = $array['mandate_id'];
        $object->proprietaryBank                        = $array['proprietary_bank'];
        $object->purposeCode                            = $array['purpose_code'];
        $object->remittanceInformationStructured        = $array['remittance_information_structured'];
        $object->remittanceInformationStructuredArray   = $array['remittance_information_structured_array'];
        $object->remittanceInformationUnstructured      = $array['remittance_information_unstructured'];
        $object->remittanceInformationUnstructuredArray = $array['remittance_information_unstructured_array'];
        $object->transactionId                          = $array['transaction_id'];
        $object->ultimateCreditor                       = $array['ultimate_creditor'];
        $object->ultimateDebtor                         = $array['ultimate_debtor'];
        $object->valueDate                              = Carbon::createFromFormat(DateTimeInterface::W3C, $array['value_date']);
        $object->transactionAmount                      = $array['transaction_amount']['amount'];
        $object->currencyCode                           = $array['transaction_amount']['currency'];
        $object->accountIdentifier                      = $array['account_identifier'];

        // add tags from array, if they exist.
        if (is_array($array['tags']) && count($array['tags']) > 0) {
            $object->tags = $array['tags'];
        }

        // undocumented values:
        $object->endToEndId                             = $array['end_to_end_id'];

        // FIXME copy paste code.
        $object->debtorAccountIban                      = array_key_exists('iban', $array['debtor_account']) ? $array['debtor_account']['iban'] : '';
        $object->creditorAccountIban                    = array_key_exists('iban', $array['creditor_account']) ? $array['creditor_account']['iban'] : '';

        $object->debtorAccountBban                      = array_key_exists('bban', $array['debtor_account']) ? $array['debtor_account']['bban'] : '';
        $object->creditorAccountBban                    = array_key_exists('bban', $array['creditor_account']) ? $array['creditor_account']['bban'] : '';

        $object->debtorAccountCurrency                  = array_key_exists('currency', $array['debtor_account']) ? $array['debtor_account']['currency'] : '';
        $object->creditorAccountCurrency                = array_key_exists('currency', $array['creditor_account']) ? $array['creditor_account']['currency'] : '';

        // $object-> = $array[''];

        // generate transactionID if empty:
        if ('' === $object->transactionId) {
            $hash                  = hash('sha256', (string) microtime());

            try {
                $hash = hash('sha256', json_encode($array, JSON_THROW_ON_ERROR));
            } catch (JsonException $e) {
                Log::error(sprintf('Could not parse array into JSON: %s', $e->getMessage()));
            }
            $object->transactionId = sprintf('ff3-%s', Uuid::uuid5(config('importer.namespace'), $hash));
        }

        return $object;
    }

    public function getDate(): Carbon
    {
        if ($this->bookingDate instanceof Carbon) {
            Log::debug('Returning book date');

            return $this->bookingDate;
        }
        if ($this->valueDate instanceof Carbon) {
            Log::debug('Returning value date');

            return $this->valueDate;
        }
        Log::warning('Transaction has no date, return NOW.');

        return new Carbon(config('app.timezone'));
    }

    /**
     * Return transaction description, which depends on the values in the object:
     */
    public function getDescription(): string
    {
        $description = '';
        if ('' !== $this->remittanceInformationUnstructured) {
            $description = $this->remittanceInformationUnstructured;
            Log::debug('Description is now remittanceInformationUnstructured');
        }

        // try other values as well (Revolut)
        if ('' === $description && count($this->remittanceInformationUnstructuredArray) > 0) {
            $description = implode(' ', $this->remittanceInformationUnstructuredArray);
            Log::debug('Description is now remittanceInformationUnstructuredArray');
        }
        if ('' === $description) {
            Log::debug('Description is now remittanceInformationStructured');
            $description = $this->remittanceInformationStructured;
        }
        if ('' === $description) {
            Log::debug('Description is now additionalInformation');
            $description = $this->additionalInformation;
        }
        $description = trim($description);

        if ('' === $description) {
            Log::warning(sprintf('Transaction "%s" has no description.', $this->getTransactionId()));
            $description = '(no description)';
        }

        return $description;
    }

    public function getCleanDescription(): string
    {
        $description = $this->getDescription();
        if (str_contains("\n", $description)) {
            // return without newlines.
            $description = str_replace(["\n", "\t"], ' ', $description);
            $description = str_replace("\r", '', $description);
        }

        return $description;
    }

    public function getTransactionId(): string
    {
        // #10914 add account ID to transaction ID to make it unique.
        $accountId     = substr(trim((string) preg_replace('/\s+/', ' ', $this->accountIdentifier)), 0, 125);
        $transactionId = substr(trim((string) preg_replace('/\s+/', ' ', $this->transactionId)), 0, 125);

        Log::debug(sprintf('Returning transaction ID: %s and %s are joined.', $accountId, $transactionId));

        return trim(sprintf('%s-%s', $accountId, $transactionId));
    }

    /**
     * Return IBAN of the destination account
     */
    public function getDestinationIban(): ?string
    {
        Log::debug(__METHOD__);
        if ('' !== $this->creditorAccountIban) {
            $data      = ['iban' => $this->creditorAccountIban];
            $rules     = ['iban' => ['required', new Iban()]];
            $validator = Validator::make($data, $rules);
            if ($validator->fails()) {
                Log::warning(sprintf('Destination IBAN is "%s" (creditor), but it is invalid, so ignoring', $this->creditorAccountIban));

                return null;
            }

            Log::debug(sprintf('Destination IBAN is "%s" (creditor)', $this->creditorAccountIban));

            return $this->creditorAccountIban;
        }
        Log::warning(sprintf('Transaction "%s" has no destination IBAN information.', $this->getTransactionId()));

        return null;
    }

    /**
     * Return name of the destination account
     */
    public function getDestinationName(): ?string
    {
        Log::debug(__METHOD__);
        if ('' !== $this->ultimateCreditor) {
            Log::debug(sprintf('Destination name is "%s" (ultimateCreditor)', $this->ultimateCreditor));

            return $this->ultimateCreditor;
        }
        if ('' !== $this->creditorName) {
            Log::debug(sprintf('Destination name is "%s" (creditorName)', $this->creditorName));

            return $this->creditorName;
        }
        Log::warning(sprintf('Transaction "%s" has no destination account name information.', $this->getTransactionId()));

        return null;
    }

    /**
     * Return IBAN of the destination account
     */
    public function getDestinationNumber(): ?string
    {
        Log::debug(__METHOD__);
        if ('' !== $this->creditorAccountBban) {
            Log::debug(sprintf('Destination BBAN is "%s" (creditor)', $this->creditorAccountBban));

            return $this->creditorAccountBban;
        }
        Log::warning(sprintf('Transaction "%s" has no destination BBAN information.', $this->getTransactionId()));

        return null;
    }

    /**
     * Returns notes based on info in the transaction.
     */
    public function getNotes(): string
    {
        $notes = '';
        if ('' !== $this->additionalInformation) {
            $notes = $this->additionalInformation;
        }

        // Add currency exchange information if available
        if (count($this->currencyExchange) > 0) {
            $exchangeInfo = $this->formatCurrencyExchangeInfo();
            $notes        = trim(sprintf("%s\n\n%s", $notes, $exchangeInfo));
        }

        // room for other fields
        if (str_contains("\n", $this->getDescription())) {
            $notes .= "\n\n".$this->getDescription();
        }

        return trim($notes);
    }

    /**
     * Format currency exchange information for inclusion in notes.
     * Returns as a list.
     */
    private function formatCurrencyExchangeInfo(): string
    {
        $info = [];

        // Add exchange rate if available and not zero

        if (array_key_exists('exchangeRate', $this->currencyExchange) && 0.0 !== (float) $this->currencyExchange['exchangeRate']) {
            $info[] = sprintf('- Exchange rate: %s', $this->currencyExchange['exchangeRate']);
        }

        // Add source and target currencies if available
        if (array_key_exists('sourceCurrency', $this->currencyExchange) && array_key_exists('targetCurrency', $this->currencyExchange)) {
            $info[] = sprintf('- Currency exchange: %s → %s', $this->currencyExchange['sourceCurrency'], $this->currencyExchange['targetCurrency']);
        }

        return implode("\n", $info);
    }

    /**
     * Return IBAN of the source account.
     */
    public function getSourceIban(): ?string
    {
        Log::debug(__METHOD__);
        if ('' !== $this->debtorAccountIban) {
            $data      = ['iban' => $this->debtorAccountIban];
            $rules     = ['iban' => ['required', new Iban()]];
            $validator = Validator::make($data, $rules);
            if ($validator->fails()) {
                Log::warning(sprintf('Source IBAN is "%s" (debtor), but it is invalid, so ignoring', $this->debtorAccountIban));

                return null;
            }

            Log::debug(sprintf('Source IBAN is "%s" (debtor)', $this->debtorAccountIban));

            return $this->debtorAccountIban;
        }
        Log::warning(sprintf('Transaction "%s" has no source IBAN information.', $this->getTransactionId()));

        return null;
    }

    /**
     * Return name of the source account.
     */
    public function getSourceName(): ?string
    {
        Log::debug(__METHOD__);
        if ('' !== $this->ultimateDebtor) {
            Log::debug(sprintf('Source name is "%s" (ultimateDebtor)', $this->ultimateDebtor));

            return $this->ultimateDebtor;
        }
        if ('' !== $this->debtorName) {
            Log::debug(sprintf('Source name is "%s" (debtorName)', $this->debtorName));

            return $this->debtorName;
        }
        Log::warning(sprintf('Transaction "%s" has no source account name information.', $this->getTransactionId()));

        return null;
    }

    /**
     * Return account number of the source account.
     */
    public function getSourceNumber(): ?string
    {
        Log::debug(__METHOD__);
        if ('' !== $this->debtorAccountBban) {
            Log::debug(sprintf('Source BBAN is "%s" (debtor)', $this->debtorAccountBban));

            return $this->debtorAccountBban;
        }
        Log::warning(sprintf('Transaction "%s" has no source BBAN information.', $this->getTransactionId()));

        return null;
    }

    public function getValueDate(): ?Carbon
    {
        if ($this->valueDate instanceof Carbon) {
            Log::debug('Returning value date for getValueDate');

            return $this->valueDate;
        }
        Log::warning('Transaction has no valueDate, return NULL.');

        return null;
    }

    /**
     * Call this "toLocalArray" because we want to confusion with "fromArray", which is really based
     * on Nordigen information. Likewise, there is also "fromLocalArray".
     */
    public function toLocalArray(): array
    {
        return [
            'additional_information'                    => $this->additionalInformation,
            'additional_information_structured'         => $this->additionalInformationStructured,
            'balance_after_transaction'                 => $this->balanceAfterTransaction->toLocalArray(),
            'bank_transaction_code'                     => $this->bankTransactionCode,
            'booking_date'                              => $this->bookingDate->toW3cString(),
            'check_id'                                  => $this->checkId,
            'creditor_agent'                            => $this->creditorAgent,
            'creditor_id'                               => $this->creditorId,
            'creditor_name'                             => $this->creditorName,
            'currency_exchange'                         => $this->currencyExchange,
            'debtor_agent'                              => $this->debtorAgent,
            'tags'                                      => $this->tags,
            'merchant_category_code'                    => $this->merchantCategoryCode,
            'debtor_name'                               => $this->debtorName,
            'entry_reference'                           => $this->entryReference,
            'key'                                       => $this->key,
            'mandate_id'                                => $this->mandateId,
            'proprietary_bank'                          => $this->proprietaryBank,
            'purpose_code'                              => $this->purposeCode,
            'remittance_information_structured'         => $this->remittanceInformationStructured,
            'remittance_information_structured_array'   => $this->remittanceInformationStructuredArray,
            'remittance_information_unstructured'       => $this->remittanceInformationUnstructured,
            'remittance_information_unstructured_array' => $this->remittanceInformationUnstructuredArray,
            'transaction_id'                            => $this->getTransactionId(),
            'ultimate_creditor'                         => $this->ultimateCreditor,
            'ultimate_debtor'                           => $this->ultimateDebtor,
            'value_date'                                => $this->valueDate->toW3cString(),
            'account_identifier'                        => $this->accountIdentifier,
            // array values:
            'debtor_account'                            => ['iban' => $this->debtorAccountIban, 'currency' => $this->debtorAccountCurrency],
            'creditor_account'                          => ['iban' => $this->creditorAccountIban, 'currency' => $this->creditorAccountCurrency],
            'transaction_amount'                        => ['amount' => $this->transactionAmount, 'currency' => $this->currencyCode],

            // undocumented values:
            'end_to_end_id'                             => $this->endToEndId,
        ];
    }
}
