<?php
if (!defined('ABSPATH')) {
    exit;
}

final class DdvWooFiscalDocument
{
    public $id;
    public $id_order;
    public $document_type;
    public $source_id;
    public $source_invoice_id;
    public $series;
    public $sequence;
    public $number;
    public $issue_date;
    public $date_add;
    public $total_paid_tax_excl;
    public $total_paid_tax_incl;
    public $currency;
    public $snapshot;
    public $snapshot_json;
    public $snapshot_sha256;

    public function __construct(array $row)
    {
        $this->id = (int) $row['id_document'];
        $this->id_order = (int) $row['id_order'];
        $this->document_type = (string) $row['document_type'];
        $this->source_id = (int) $row['source_id'];
        $this->source_invoice_id = !empty($row['source_invoice_id']) ? (int) $row['source_invoice_id'] : 0;
        $this->series = (string) $row['series'];
        $this->sequence = (int) $row['sequence_no'];
        $this->number = (string) $row['document_number'];
        $this->issue_date = (string) $row['issue_date'];
        $this->date_add = (string) $row['created_at'];
        $this->total_paid_tax_excl = (float) $row['total_tax_excl'];
        $this->total_paid_tax_incl = (float) $row['total_tax_incl'];
        $this->currency = (string) $row['currency'];
        $this->snapshot_json = (string) $row['snapshot_json'];
        $snapshot = json_decode($this->snapshot_json, true);
        $this->snapshot = is_array($snapshot) ? $snapshot : array();
        $this->snapshot_sha256 = (string) $row['snapshot_sha256'];
    }
}

/**
 * WordPress/WooCommerce fiscal document layer.
 *
 * WooCommerce itself has orders and refunds but no immutable numbered invoice
 * entity in core. This repository creates that missing fiscal boundary: a
 * numbered immutable snapshot is created first, and only then can VeriFactu
 * XML/huella/QR generation start.
 */
final class DdvWooDocumentRepository
{
    const TYPE_INVOICE = 'INVOICE';
    const TYPE_CREDIT = 'CREDIT_NOTE';

    public static function installSchema()
    {
        global $wpdb;
        $charset = $wpdb->get_charset_collate();
        $documents = DdvWpDb::prefix() . 'ddverifactu_documents';
        $sequences = DdvWpDb::prefix() . 'ddverifactu_sequences';

        $sql1 = "CREATE TABLE IF NOT EXISTS `{$documents}` (
            `id_document` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            `document_type` VARCHAR(20) NOT NULL,
            `source_id` BIGINT UNSIGNED NOT NULL,
            `id_order` BIGINT UNSIGNED NOT NULL,
            `source_invoice_id` BIGINT UNSIGNED DEFAULT NULL,
            `series` VARCHAR(30) NOT NULL,
            `sequence_no` BIGINT UNSIGNED NOT NULL,
            `document_number` VARCHAR(60) NOT NULL,
            `issue_date` CHAR(10) NOT NULL,
            `currency` CHAR(3) NOT NULL,
            `total_tax_excl` DECIMAL(20,6) NOT NULL,
            `total_tax_incl` DECIMAL(20,6) NOT NULL,
            `snapshot_json` LONGTEXT NOT NULL,
            `snapshot_sha256` CHAR(64) NOT NULL,
            `created_at` DATETIME NOT NULL,
            PRIMARY KEY (`id_document`),
            UNIQUE KEY `uniq_type_source` (`document_type`,`source_id`),
            UNIQUE KEY `uniq_series_sequence` (`series`,`sequence_no`),
            UNIQUE KEY `uniq_document_number` (`document_number`),
            KEY `idx_order` (`id_order`,`document_type`,`id_document`)
        ) ENGINE=InnoDB {$charset}";

        $sql2 = "CREATE TABLE IF NOT EXISTS `{$sequences}` (
            `series_key` VARCHAR(191) NOT NULL,
            `last_number` BIGINT UNSIGNED NOT NULL DEFAULT 0,
            `updated_at` DATETIME NOT NULL,
            PRIMARY KEY (`series_key`)
        ) ENGINE=InnoDB {$charset}";

        return $wpdb->query($sql1) !== false && $wpdb->query($sql2) !== false;
    }

    public function issueInvoiceForOrder($order, array $options = array())
    {
        $order = $this->requireOrder($order);
        $existing = $this->getInvoiceByOrder((int) $order->get_id());
        if ($existing) {
            return $existing;
        }
        if ($order instanceof WC_Order_Refund) {
            throw new Exception('Un abono de WooCommerce no puede emitirse como factura ordinaria.');
        }

        $version = (string) DdvWpConfigStore::get('DD_VF_MODULE_VERSION');
        VerifactuConfig::validateRecordConfiguration($version !== '' ? $version : '1.2.16.4');
        $snapshot = DdvWooSnapshot::fromOrder($order, $options);
        VerifactuInvoiceData::validateInvoiceSnapshotForIssuance($snapshot);
        $series = $this->normaliseSeries((string) DdvWpConfigStore::get('DD_VF_WC_INVOICE_PREFIX'), 'F-');
        $start = max(1, (int) DdvWpConfigStore::get('DD_VF_WC_INVOICE_START'));
        return $this->createDocument(self::TYPE_INVOICE, (int) $order->get_id(), $order, $series, $start, $snapshot, 0);
    }

    public function ensureCreditNoteForRefund($refund, array $options = array())
    {
        $refund = is_numeric($refund) ? wc_get_order((int) $refund) : $refund;
        if (!($refund instanceof WC_Order_Refund)) {
            throw new Exception('El abono de WooCommerce no existe.');
        }
        $existing = $this->getCreditNoteByRefund((int) $refund->get_id());
        if ($existing) {
            return $existing;
        }
        $order = wc_get_order((int) $refund->get_parent_id());
        $order = $this->requireOrder($order);
        $version = (string) DdvWpConfigStore::get('DD_VF_MODULE_VERSION');
        VerifactuConfig::validateRecordConfiguration($version !== '' ? $version : '1.2.16.4');
        $sourceInvoice = $this->getInvoiceByOrder((int) $order->get_id());
        if (!$sourceInvoice) {
            throw new Exception('No existe una factura fiscal emitida para el pedido original. No se creara una rectificativa huerfana.');
        }
        $snapshot = DdvWooSnapshot::fromRefund($order, $refund, $options);
        VerifactuInvoiceData::validateCreditSnapshotForIssuance($snapshot);
        $series = $this->normaliseSeries((string) DdvWpConfigStore::get('DD_VF_WC_CREDIT_PREFIX'), 'R-');
        $start = max(1, (int) DdvWpConfigStore::get('DD_VF_WC_CREDIT_START'));
        return $this->createDocument(
            self::TYPE_CREDIT,
            (int) $refund->get_id(),
            $order,
            $series,
            $start,
            $snapshot,
            (int) $sourceInvoice->id
        );
    }

    public function getInvoiceById($id)
    {
        return $this->getByIdAndType((int) $id, self::TYPE_INVOICE);
    }

    public function getInvoiceByOrder($idOrder)
    {
        global $wpdb;
        $table = DdvWpDb::prefix() . 'ddverifactu_documents';
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM `{$table}` WHERE `document_type`=%s AND `id_order`=%d ORDER BY `id_document` ASC LIMIT 1",
            self::TYPE_INVOICE,
            (int) $idOrder
        ), ARRAY_A);
        return is_array($row) ? new DdvWooFiscalDocument($row) : null;
    }

    public function getInvoicesByOrder($idOrder)
    {
        global $wpdb;
        $table = DdvWpDb::prefix() . 'ddverifactu_documents';
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM `{$table}` WHERE `document_type`=%s AND `id_order`=%d ORDER BY `id_document` ASC",
            self::TYPE_INVOICE,
            (int) $idOrder
        ), ARRAY_A);
        return array_map(static function ($row) { return new DdvWooFiscalDocument($row); }, is_array($rows) ? $rows : array());
    }

    public function getCreditNoteByRefund($idRefund)
    {
        global $wpdb;
        $table = DdvWpDb::prefix() . 'ddverifactu_documents';
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM `{$table}` WHERE `document_type`=%s AND `source_id`=%d LIMIT 1",
            self::TYPE_CREDIT,
            (int) $idRefund
        ), ARRAY_A);
        return is_array($row) ? new DdvWooFiscalDocument($row) : null;
    }

    public function listDocuments($limit = 100)
    {
        global $wpdb;
        $table = DdvWpDb::prefix() . 'ddverifactu_documents';
        $limit = max(1, min(500, (int) $limit));
        $rows = $wpdb->get_results("SELECT * FROM `{$table}` ORDER BY `id_document` DESC LIMIT {$limit}", ARRAY_A);
        return array_map(static function ($row) { return new DdvWooFiscalDocument($row); }, is_array($rows) ? $rows : array());
    }

    private function getByIdAndType($id, $type)
    {
        global $wpdb;
        $table = DdvWpDb::prefix() . 'ddverifactu_documents';
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM `{$table}` WHERE `id_document`=%d AND `document_type`=%s LIMIT 1",
            (int) $id,
            (string) $type
        ), ARRAY_A);
        return is_array($row) ? new DdvWooFiscalDocument($row) : null;
    }

    private function createDocument($type, $sourceId, $order, $series, $start, array $snapshot, $sourceInvoiceId)
    {
        global $wpdb;
        $documents = DdvWpDb::prefix() . 'ddverifactu_documents';
        $sequences = DdvWpDb::prefix() . 'ddverifactu_sequences';
        $lockName = 'ddvseq_' . substr(hash('sha256', $type . '|' . $series), 0, 48);
        $gotLock = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10));
        if ($gotLock !== 1) {
            throw new Exception('No se ha podido obtener el bloqueo de numeracion fiscal. Reintente la operacion.');
        }
        try {
            $duplicate = $type === self::TYPE_INVOICE
                ? $this->getInvoiceByOrder((int) $order->get_id())
                : $this->getCreditNoteByRefund((int) $sourceId);
            if ($duplicate) {
                return $duplicate;
            }

            $wpdb->query('START TRANSACTION');
            $last = $wpdb->get_var($wpdb->prepare(
                "SELECT `last_number` FROM `{$sequences}` WHERE `series_key`=%s FOR UPDATE",
                $type . '|' . $series
            ));
            $next = $last === null ? (int) $start : ((int) $last + 1);
            $number = $series . str_pad((string) $next, 6, '0', STR_PAD_LEFT);
            if (strlen($number) > 60) {
                throw new Exception('La serie configurada produce un numero de factura demasiado largo.');
            }
            $snapshotJson = wp_json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
            if (!is_string($snapshotJson) || $snapshotJson === '') {
                throw new Exception('No se ha podido conservar la instantanea fiscal del documento.');
            }
            $created = current_time('mysql', true);
            $issueDate = !empty($snapshot['issue_date']) ? (string) $snapshot['issue_date'] : gmdate('d-m-Y');
            $inserted = $wpdb->insert($documents, array(
                'document_type' => $type,
                'source_id' => (int) $sourceId,
                'id_order' => (int) $order->get_id(),
                'source_invoice_id' => $sourceInvoiceId > 0 ? (int) $sourceInvoiceId : null,
                'series' => $series,
                'sequence_no' => $next,
                'document_number' => $number,
                'issue_date' => $issueDate,
                'currency' => (string) $snapshot['currency'],
                'total_tax_excl' => (string) $snapshot['total_excl'],
                'total_tax_incl' => (string) $snapshot['total_incl'],
                'snapshot_json' => $snapshotJson,
                'snapshot_sha256' => hash('sha256', $snapshotJson),
                'created_at' => $created,
            ));
            if ($inserted === false) {
                throw new Exception('No se ha podido crear el documento fiscal: ' . $wpdb->last_error);
            }
            $id = (int) $wpdb->insert_id;
            $seqResult = $wpdb->query($wpdb->prepare(
                "INSERT INTO `{$sequences}` (`series_key`,`last_number`,`updated_at`) VALUES (%s,%d,%s)
                 ON DUPLICATE KEY UPDATE `last_number`=VALUES(`last_number`),`updated_at`=VALUES(`updated_at`)",
                $type . '|' . $series,
                $next,
                $created
            ));
            if ($seqResult === false) {
                throw new Exception('No se ha podido consolidar la numeracion fiscal.');
            }
            $wpdb->query('COMMIT');
            return $this->getByIdAndType($id, $type);
        } catch (Throwable $e) {
            $wpdb->query('ROLLBACK');
            throw $e;
        } finally {
            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
        }
    }

    private function normaliseSeries($value, $fallback)
    {
        $value = trim((string) $value);
        if ($value === '') {
            $value = $fallback;
        }
        if (!preg_match('/^[A-Za-z0-9._\/-]{1,30}$/', $value)) {
            throw new Exception('La serie fiscal solo puede contener letras, numeros, punto, guion, barra y guion bajo.');
        }
        return $value;
    }

    private function requireOrder($order)
    {
        if (is_numeric($order)) {
            $order = wc_get_order((int) $order);
        }
        if (!($order instanceof WC_Order)) {
            throw new Exception('El pedido de WooCommerce no existe.');
        }
        return $order;
    }
}

final class DdvWooSnapshot
{
    public static function fromOrder(WC_Order $order, array $options = array())
    {
        $currency = strtoupper((string) $order->get_currency());
        if ($currency !== 'EUR') {
            throw new Exception('VeriFactu se ha configurado para importes en EUR; el pedido usa ' . $currency . '.');
        }
        $issuedAt = new DateTimeImmutable('now', VerifactuConfig::getTimezone());
        $totalIncl = self::money($order->get_total());
        $totalTax = self::money($order->get_total_tax());
        $totalExcl = self::money($totalIncl - $totalTax);
        if ($totalIncl < -0.0051) {
            throw new Exception('Una factura ordinaria no puede tener importe total negativo.');
        }
        return array(
            'platform' => 'woocommerce',
            'source_kind' => 'order',
            'source_id' => (int) $order->get_id(),
            'order_number' => (string) $order->get_order_number(),
            'currency' => $currency,
            'issue_date' => $issuedAt->format('d-m-Y'),
            'created_at' => $issuedAt->format(DATE_ATOM),
            'total_excl' => self::amount($totalExcl),
            'total_tax' => self::amount($totalTax),
            'total_incl' => self::amount($totalIncl),
            'recipient' => self::recipient($order, $options),
            'billing_address' => self::billingAddress($order),
            'issuer_address' => self::issuerAddress(),
            'items' => self::displayItems($order, false),
            'tax_rows' => self::taxRows($order, false),
            'fiscal_note' => self::fiscalNote($order, $options),
        );
    }

    public static function fromRefund(WC_Order $order, WC_Order_Refund $refund, array $options = array())
    {
        $currency = strtoupper((string) $order->get_currency());
        if ($currency !== 'EUR') {
            throw new Exception('VeriFactu se ha configurado para importes en EUR; el pedido usa ' . $currency . '.');
        }
        $issuedAt = $refund->get_date_created();
        $issuedAt = $issuedAt ? DateTimeImmutable::createFromFormat('U', (string) $issuedAt->getTimestamp())->setTimezone(VerifactuConfig::getTimezone()) : new DateTimeImmutable('now', VerifactuConfig::getTimezone());
        $gross = abs((float) $refund->get_total());
        if ($gross < 0.0051 && method_exists($refund, 'get_amount')) {
            $gross = abs((float) $refund->get_amount());
        }
        $tax = abs((float) $refund->get_total_tax());
        $base = max(0.0, $gross - $tax);
        return array(
            'platform' => 'woocommerce',
            'source_kind' => 'refund',
            'source_id' => (int) $refund->get_id(),
            'order_id' => (int) $order->get_id(),
            'currency' => $currency,
            'issue_date' => $issuedAt->format('d-m-Y'),
            'created_at' => $issuedAt->format(DATE_ATOM),
            'total_excl' => self::amount(self::money($base)),
            'total_tax' => self::amount(self::money($tax)),
            'total_incl' => self::amount(self::money($gross)),
            'recipient' => self::recipient($order, $options),
            'billing_address' => self::billingAddress($order),
            'issuer_address' => self::issuerAddress(),
            'items' => self::displayItems($refund, true),
            'tax_rows' => self::taxRows($refund, true),
            'fiscal_note' => self::fiscalNote($order, $options),
            'refund_reason' => (string) $refund->get_reason(),
        );
    }

    private static function recipient(WC_Order $order, array $options)
    {
        $company = trim((string) $order->get_billing_company());
        $person = trim((string) $order->get_billing_first_name() . ' ' . (string) $order->get_billing_last_name());
        $name = $company !== '' ? $company : $person;
        if ($name === '') {
            throw new Exception('El destinatario no tiene nombre o razon social de facturacion.');
        }
        $country = strtoupper(trim((string) $order->get_billing_country()));
        if ($country === '') {
            throw new Exception('El pedido no tiene pais de facturacion.');
        }
        $taxId = isset($options['tax_id']) ? (string) $options['tax_id'] : '';
        if ($taxId === '') {
            foreach (array('_ddv_tax_id','_billing_nif','billing_nif','_billing_vat','billing_vat','_billing_vat_number','vat_number','_billing_dni','dni') as $key) {
                $candidate = trim((string) $order->get_meta($key, true));
                if ($candidate !== '') {
                    $taxId = $candidate;
                    break;
                }
            }
        }
        $taxId = (string) apply_filters('ddverifactu_recipient_tax_id', $taxId, $order);
        $taxId = preg_replace('/[^A-Za-z0-9]/', '', strtoupper($taxId));
        return array(
            'name' => self::truncate($name, 120),
            'company' => self::truncate($company, 120),
            'tax_id' => $taxId,
            'country_code' => $country,
            'postcode' => preg_replace('/\s+/', '', (string) $order->get_billing_postcode()),
            'state' => strtoupper(trim((string) $order->get_billing_state())),
        );
    }

    private static function billingAddress(WC_Order $order)
    {
        return array(
            'first_name' => (string) $order->get_billing_first_name(),
            'last_name' => (string) $order->get_billing_last_name(),
            'company' => (string) $order->get_billing_company(),
            'address_1' => (string) $order->get_billing_address_1(),
            'address_2' => (string) $order->get_billing_address_2(),
            'postcode' => (string) $order->get_billing_postcode(),
            'city' => (string) $order->get_billing_city(),
            'state' => (string) $order->get_billing_state(),
            'country' => (string) $order->get_billing_country(),
            'email' => (string) $order->get_billing_email(),
        );
    }

    private static function issuerAddress()
    {
        $address = array(
            'address_1' => '',
            'address_2' => '',
            'postcode' => '',
            'city' => '',
            'state' => '',
            'country' => '',
            'email' => (string) get_option('admin_email', ''),
        );
        if (function_exists('WC') && WC() && isset(WC()->countries) && WC()->countries) {
            $address['address_1'] = (string) WC()->countries->get_base_address();
            $address['address_2'] = method_exists(WC()->countries, 'get_base_address_2') ? (string) WC()->countries->get_base_address_2() : '';
            $address['postcode'] = (string) WC()->countries->get_base_postcode();
            $address['city'] = (string) WC()->countries->get_base_city();
            $address['state'] = (string) WC()->countries->get_base_state();
            $address['country'] = (string) WC()->countries->get_base_country();
        }
        return $address;
    }

    private static function displayItems($document, $absolute)
    {
        $result = array();
        foreach (array('line_item' => 'product', 'shipping' => 'shipping', 'fee' => 'fee') as $type => $kind) {
            foreach ($document->get_items($type) as $item) {
                $base = method_exists($item, 'get_total') ? (float) $item->get_total() : 0.0;
                $tax = method_exists($item, 'get_total_tax') ? (float) $item->get_total_tax() : 0.0;
                $qty = method_exists($item, 'get_quantity') ? (float) $item->get_quantity() : 1.0;
                if ($absolute) {
                    $base = abs($base);
                    $tax = abs($tax);
                    $qty = abs($qty);
                }
                $result[] = array(
                    'kind' => $kind,
                    'name' => method_exists($item, 'get_name') ? self::truncate((string) $item->get_name(), 250) : ucfirst($kind),
                    'quantity' => $qty == 0 ? 1 : $qty,
                    'base' => self::amount($base),
                    'tax' => self::amount($tax),
                    'total' => self::amount($base + $tax),
                );
            }
        }
        return $result;
    }

    private static function fiscalNote(WC_Order $order, array $options)
    {
        if (isset($options['fiscal_note'])) {
            return self::truncate((string) $options['fiscal_note'], 1000);
        }
        return self::truncate((string) $order->get_meta('_ddv_fiscal_note', true), 1000);
    }

    private static function taxRows($document, $absolute)
    {
        $rateMap = array();
        if ($document instanceof WC_Order) {
            foreach ($document->get_items('tax') as $taxItem) {
                if (method_exists($taxItem, 'get_rate_id')) {
                    $rateMap[(int) $taxItem->get_rate_id()] = method_exists($taxItem, 'get_rate_percent')
                        ? (float) $taxItem->get_rate_percent()
                        : 0.0;
                }
            }
        }
        if ($document instanceof WC_Order_Refund) {
            $parent = wc_get_order((int) $document->get_parent_id());
            if ($parent instanceof WC_Order) {
                foreach ($parent->get_items('tax') as $taxItem) {
                    if (method_exists($taxItem, 'get_rate_id')) {
                        $rateMap[(int) $taxItem->get_rate_id()] = method_exists($taxItem, 'get_rate_percent')
                            ? (float) $taxItem->get_rate_percent()
                            : 0.0;
                    }
                }
            }
        }

        $rows = array();
        foreach (array('line_item' => 'products', 'shipping' => 'shipping', 'fee' => 'fees') as $itemType => $source) {
            foreach ($document->get_items($itemType) as $item) {
                if (!method_exists($item, 'get_total')) {
                    continue;
                }
                $base = (float) $item->get_total();
                $tax = method_exists($item, 'get_total_tax') ? (float) $item->get_total_tax() : 0.0;
                if ($absolute) {
                    $base = abs($base);
                    $tax = abs($tax);
                }
                $taxes = method_exists($item, 'get_taxes') ? (array) $item->get_taxes() : array();
                $parts = isset($taxes['total']) && is_array($taxes['total']) ? $taxes['total'] : array();
                $nonZeroParts = array_filter($parts, static function ($v) { return abs((float) $v) > 0.000001; });
                if (count($nonZeroParts) > 1 && !(bool) DdvWpConfigStore::get('DD_VF_COMPOUND_TAX_REVIEWED')) {
                    throw new Exception('El documento contiene mas de un impuesto por linea. Revise expresamente los impuestos compuestos antes de emitir el documento fiscal.');
                }
                if (empty($parts)) {
                    if (abs($base) > 0.000001 || abs($tax) > 0.000001) {
                        $rows[] = array('id_tax' => 0, 'rate' => 0.0, 'base' => $base, 'tax' => $tax, 'source' => $source);
                    }
                    continue;
                }
                if (count($parts) === 1) {
                    $rateId = (int) array_key_first($parts);
                    $partTax = (float) reset($parts);
                    if ($absolute) { $partTax = abs($partTax); }
                    $rate = isset($rateMap[$rateId]) ? (float) $rateMap[$rateId] : (abs($base) > 0.000001 ? abs($partTax / $base * 100) : 0.0);
                    $rows[] = array('id_tax' => $rateId, 'rate' => $rate, 'base' => $base, 'tax' => $partTax, 'source' => $source);
                    continue;
                }
                // Multiple zero-tax rate ids are still ambiguous for fiscal mapping.
                if (count($parts) > 1) {
                    throw new Exception('El documento contiene multiples impuestos asociados a una misma linea y no puede clasificarse de forma inequivoca.');
                }
            }
        }
        return $rows;
    }

    private static function money($value)
    {
        return round((float) $value, 2);
    }

    private static function amount($value)
    {
        return number_format((float) $value, 2, '.', '');
    }

    private static function truncate($value, $length)
    {
        $value = trim(wp_strip_all_tags((string) $value));
        return function_exists('mb_substr') ? mb_substr($value, 0, $length, 'UTF-8') : substr($value, 0, $length);
    }
}
