import { Injectable } from "@nestjs/common";
import { EntityManager } from "typeorm";
import DocumentEntity from "../../entity/document.entity";
import DocumentRowEntity from "../../entity/document_row.entity";
import DocumentCustomerEntity from "../../entity/document_customer.entity";
import DocumentPaymentEntity from "../../entity/document_payment.entity";
import { create } from 'xmlbuilder2';
import DocumentStoreEntity from "../../entity/document_store.entity";
import StoreEntity from "../../entity/store.entity";
import CustomerEntity from "../../entity/customer.entity";
import { DocumentCreateDto } from "../../dto/document.dto";
import ResponseDto from "../../dto/response.dto";
import { natures } from "../../types/constants";
import DocumentDiscountEntity from "../../entity/document_discount.entity";

const NAMESPACE_FATTURE = "http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2";
const NAMESPACE_DS      = "http://www.w3.org/2000/09/xmldsig#";
const NAMESPACE_XSI     = "http://www.w3.org/2001/XMLSchema-instance";

// Nome (max 10 caratteri, String10Type) del software che ha generato l'XML: attributo facoltativo
// introdotto dalla versione 2021 dello schema, richiesto a fini statistici da SdI.
const SISTEMA_EMITTENTE = "WEARE";

@Injectable()
export default class InvoiceProvider {

    constructor(private readonly entityManager: EntityManager){}

    /**
     * Create XML invoice
     * @param id
     */
    async createXML(id: number, txn: EntityManager): Promise<string | null>{

        let xml: string | null = null;

        try{

            const document =
                await txn.findOneOrFail(
                    DocumentEntity,
                    {
                        where: {
                            id: id
                        },
                        relations: {
                            refDocument: true
                        }
                    }
                );

            const rows: DocumentRowEntity[] =
                await txn.find(
                    DocumentRowEntity,
                    {
                        where: {
                            document: {
                                id: id
                            }
                        }
                    }
                );
            
            const discounts: DocumentDiscountEntity[] = 
                await txn.find(
                    DocumentDiscountEntity,
                    {
                        where: {
                            document: {
                                id: id
                            }
                        }
                    }
                );

            if(rows.length === 0){
                throw new Error("Il documento non contiene righe");
            }

            const documentCustomer =
                await txn.findOneOrFail(
                    DocumentCustomerEntity,
                    {
                        where: {
                            document: {
                                id: id
                            }
                        }
                    }
                );

            const documentPayments: DocumentPaymentEntity[] =
                await txn.find(
                    DocumentPaymentEntity,
                    {
                        where: {
                            document: {
                                id: id
                            }
                        }
                    }
                );

            const documentStore: DocumentStoreEntity =
                await txn.findOneOrFail(
                    DocumentStoreEntity,
                    {
                        where: {
                            document: {
                                id: id
                            }
                        },
                        relations: {
                            store: true
                        }
                    }
                );

            const store = documentStore.store;

            if(!store){
                throw new Error("Negozio emittente non trovato per il documento");
            }

            // ________ Cedente / Prestatore (soggetto emittente) ________

            const cedenteNazione = (store.countryCode || "IT").toUpperCase();

            const regimeFiscale = this.required(store.fiscalRegim, "Regime fiscale del negozio emittente non impostato").toUpperCase();
            if(!/^RF\d{2}$/.test(regimeFiscale)){
                throw new Error(`Regime fiscale del negozio emittente non valido: "${store.fiscalRegim}"`);
            }

            const cedentePrestatore = {
                DatiAnagrafici: {
                    IdFiscaleIVA: {
                        IdPaese: cedenteNazione,
                        IdCodice: this.required(store.vatNumber, "Partita IVA del negozio emittente non impostata")
                    },
                    ...(store.fiscalCode ? { CodiceFiscale: store.fiscalCode.toUpperCase() } : {}),
                    Anagrafica: {
                        Denominazione: this.truncate(store.businessName || store.title, 80)
                    },
                    RegimeFiscale: regimeFiscale
                },
                Sede: {
                    Indirizzo: this.required(store.address, "Indirizzo del negozio emittente non impostato"),
                    CAP: this.requiredCap(store.zipCode, "negozio emittente"),
                    Comune: this.required(store.city, "Comune del negozio emittente non impostato"),
                    ...(store.province ? { Provincia: store.province.toUpperCase() } : {}),
                    Nazione: cedenteNazione
                },
                ...((store.phoneNumber || store.email) ? {
                    Contatti: {
                        ...(store.phoneNumber ? { Telefono: store.phoneNumber } : {}),
                        ...(store.email ? { Email: store.email } : {})
                    }
                } : {})
            };

            // ________ Dati Trasmissione ________
            // Il negozio emittente trasmette direttamente la fattura: l'IdTrasmittente coincide
            // con i dati fiscali del Cedente/Prestatore.

            const progressivoInvio =
                String(document.documentProgressive ?? document.id ?? 0)
                    .padStart(5, "0")
                    .slice(-10);

            const { codiceDestinatario, pecDestinatario } =
                this.resolveDestinatario(documentCustomer.sdiCode, documentCustomer.pec);

            const datiTrasmissione = {
                IdTrasmittente: {
                    IdPaese: cedenteNazione,
                    IdCodice: store.vatNumber
                },
                ProgressivoInvio: progressivoInvio,
                FormatoTrasmissione: "FPR12",
                CodiceDestinatario: codiceDestinatario,
                ...(pecDestinatario ? { PECDestinatario: pecDestinatario } : {})
            };

            // ________ Cessionario / Committente (cliente) ________

            if(!documentCustomer.vatNumber && !documentCustomer.fiscalCode){
                throw new Error("Cliente privo sia di Partita IVA che di Codice Fiscale");
            }

            const cessionarioCommittente = {
                DatiAnagrafici: {
                    ...(documentCustomer.vatNumber ? {
                        IdFiscaleIVA: {
                            IdPaese: "IT",
                            IdCodice: documentCustomer.vatNumber
                        }
                    } : {}),
                    ...(documentCustomer.fiscalCode ? { CodiceFiscale: documentCustomer.fiscalCode.toUpperCase() } : {}),
                    Anagrafica: this.buildAnagrafica(
                        documentCustomer.businessName,
                        documentCustomer.firstname,
                        documentCustomer.lastname
                    )
                },
                Sede: {
                    Indirizzo: this.required(documentCustomer.address, "Indirizzo del cliente non impostato"),
                    CAP: this.requiredCap(documentCustomer.zipCode, "cliente"),
                    Comune: this.required(documentCustomer.city, "Comune del cliente non impostato"),
                    ...(documentCustomer.province ? { Provincia: documentCustomer.province.toUpperCase() } : {}),
                    Nazione: "IT"
                }
            };

            // ________ Dettaglio Linee / Dati Riepilogo ________
            //
            // Ogni importo di riga viene arrotondato una sola volta, a livello di riga, e i blocchi di
            // riepilogo vengono poi ottenuti sommando questi valori già arrotondati: in questo modo il
            // totale di riepilogo per aliquota coincide sempre, al centesimo, con la somma delle righe di
            // dettaglio (niente scarti dovuti ad arrotondare due volte lo stesso importo).

            const tipoDocumento = document.documentArea === "credit_note" ? "TD04" : "TD01";

            const riepilogoByKey = new Map<string, {
                rate: number,
                natura?: string,
                enforceability?: string,
                imponibile: number,
                imposta: number,
                righe: number
            }>();

            const dettaglioLinee = rows.map((row, index) => {

                const rate = this.toNumber(row.rate);
                let natura: string | undefined;

                if(rate === 0){
                    natura = this.required(row.nature, `Riga ${index + 1}: aliquota IVA pari a zero richiede la valorizzazione di "nature"`);
                }

                const enforceability = row.enforceability || undefined;
                const lineImponibile = this.round2(row.amountTaxable);
                const lineImposta = this.round2(row.amountTax);

                const key = `${this.toRate(rate)}|${natura ?? ""}|${enforceability ?? ""}`;
                const group = riepilogoByKey.get(key) || { rate, natura, enforceability, imponibile: 0, imposta: 0, righe: 0 };
                group.imponibile = this.round2(group.imponibile + lineImponibile);
                group.imposta = this.round2(group.imposta + lineImposta);
                group.righe += 1;
                riepilogoByKey.set(key, group);

                // SDI ricalcola PrezzoTotale come (PrezzoUnitario ± ScontoMaggiorazione) * Quantita
                // (controllo 00423), usando i valori così come compaiono nell'XML: lo sconto/maggiorazione
                // di riga va quindi derivato dagli stessi valori STAMPATI di PrezzoUnitario e Quantita (non
                // dai numeri grezzi del DB, che possono avere più decimali di quelli effettivamente scritti),
                // altrimenti un doppio arrotondamento indipendente di PrezzoUnitario e Importo introduce uno
                // scarto che si amplifica moltiplicandolo per la quantità. Per lo stesso motivo l'Importo
                // viene scritto con più decimali di quelli di un normale importo in euro (fino a 8, ammessi
                // da Amount8DecimalType) così che il conto torni in modo pressoché esatto qualunque sia la
                // quantità, anziché limitarsi ai 2 decimali "di cassa".
                const quantitaStampata = this.toNumber(this.toQuantity(row.quantity));
                const prezzoUnitarioStampato = this.toNumber(this.toAmount8(row.price));
                let scontoMaggiorazione: { Tipo: string, Importo: string }[] | undefined;

                if(quantitaStampata > 0){
                    const prezzoUnitarioNetto = lineImponibile / quantitaStampata;
                    const delta = prezzoUnitarioStampato - prezzoUnitarioNetto;
                    if(Math.abs(delta) >= 0.005){
                        scontoMaggiorazione = [{
                            Tipo: delta > 0 ? "SC" : "MG",
                            Importo: this.formatScontoImporto(delta, prezzoUnitarioStampato, quantitaStampata, lineImponibile)
                        }];
                    }
                }

                return {
                    NumeroLinea: index + 1,
                    Descrizione: this.truncate(row.title, 1000),
                    Quantita: this.toQuantity(row.quantity),
                    PrezzoUnitario: this.toAmount8(row.price),
                    ...(scontoMaggiorazione ? { ScontoMaggiorazione: scontoMaggiorazione } : {}),
                    PrezzoTotale: lineImponibile.toFixed(2),
                    AliquotaIVA: this.toRate(rate),
                    ...(natura ? { Natura: natura } : {})
                };

            });

            // L'imposta di ogni blocco di riepilogo è per definizione imponibile * aliquota / 100: è la
            // regola di calcolo richiesta da SDI, quindi la sostituiamo al valore sommato dalle righe
            // (che riflette gli arrotondamenti realmente applicati in cassa riga per riga) così che il
            // file generato sia sempre internamente coerente. Non si blocca mai la generazione per uno
            // scarto di arrotondamento: uno scarto oltre la normale tolleranza (1 centesimo ogni 2 righe
            // del blocco, minimo 1 centesimo) viene comunque corretto, e solo segnalato in log per un
            // controllo successivo, perché può indicare un'anomalia a monte (es. sconto/aliquota).
            for(const group of riepilogoByKey.values()){
                const impostaCalcolata = this.round2(group.imponibile * group.rate / 100);
                const tolleranza = this.groupTolerance(group.righe);
                const scarto = Math.abs(this.round2(group.imposta - impostaCalcolata));
                if(scarto > tolleranza){
                    console.warn(
                        `[InvoiceProvider] Documento ${id}: imposta ricalcolata per aliquota ${this.toRate(group.rate)}% ` +
                        `(da ${group.imposta.toFixed(2)} a ${impostaCalcolata.toFixed(2)}, scarto ${scarto.toFixed(2)})`
                    );
                }
                group.imposta = impostaCalcolata;
            }

            const datiRiepilogo = Array.from(riepilogoByKey.values()).map(group => ({
                AliquotaIVA: this.toRate(group.rate),
                ...(group.natura ? { Natura: group.natura } : {}),
                ImponibileImporto: group.imponibile.toFixed(2),
                Imposta: group.imposta.toFixed(2),
                ...(group.enforceability ? { EsigibilitaIVA: group.enforceability } : {}),
                ...(group.natura ? { RiferimentoNormativo: this.riferimentoNormativoPerNatura(group.natura) } : {})
            }));

            let totaleRighe = 0;
            for(const group of riepilogoByKey.values()){
                totaleRighe = this.round2(totaleRighe + group.imponibile + group.imposta);
            }

            // Sconti "sul piede" (document_discount): non toccano l'imponibile/imposta di ogni aliquota
            // (quelli restano quelli reali delle righe), ma vanno applicati in cascata sul totale
            // documento, esattamente come previsto dal blocco 2.1.1.8 <ScontoMaggiorazione> di
            // <DatiGeneraliDocumento> ("la molteplicità N del blocco consente di gestire la presenza di
            // più sconti o più maggiorazioni a 'cascata'"). Ogni riga di document_discount è trattata
            // come una percentuale applicata al totale via via scontato dai passaggi precedenti.
            let totaleScontato = totaleRighe;
            const scontoMaggiorazioneDocumento: { Tipo: string, Percentuale: string, Importo: string }[] = [];

            for(const sconto of discounts){
                const percentuale = this.toNumber(sconto.discount);
                if(Math.abs(percentuale) < 0.005){
                    continue;
                }
                const tipo = percentuale > 0 ? "SC" : "MG";
                const importoStep = this.round2(totaleScontato * Math.abs(percentuale) / 100);
                totaleScontato = this.round2(tipo === "SC" ? totaleScontato - importoStep : totaleScontato + importoStep);
                scontoMaggiorazioneDocumento.push({
                    Tipo: tipo,
                    Percentuale: this.toRate(Math.abs(percentuale)),
                    Importo: importoStep.toFixed(2)
                });
            }

            // <ImportoTotaleDocumento> è il totale RISULTANTE dai blocchi appena scritti in fattura
            // (Riepilogo + eventuale cascata di ScontoMaggiorazione "sul piede"): usiamo sempre questo
            // valore, mai `document.amount` direttamente, perché quest'ultimo può essere stato calcolato
            // o salvato a monte PRIMA che lo sconto sul piede venisse applicato (com'era il caso qui:
            // 110 invece di 55) e non è quindi una fonte affidabile per il totale da esporre in fattura.
            // `document.amount` resta comunque un utile controllo di coerenza, solo segnalato in log se
            // discorda in modo anomalo, senza mai alterare il totale calcolato né gonfiare artificialmente
            // <Arrotondamento> per "far quadrare" un valore stantio.
            const totaleDocumentoDB = this.round2(document.amount);
            const scartoVsDB = this.round2(totaleDocumentoDB - totaleScontato);

            const ANOMALY_THRESHOLD = 0.5;
            if(Math.abs(scartoVsDB) > ANOMALY_THRESHOLD){
                console.warn(
                    `[InvoiceProvider] Documento ${id}: il totale calcolato dalle righe (netto sconti sul ` +
                    `piede: ${totaleScontato.toFixed(2)}) differisce da document.amount in DB ` +
                    `(${totaleDocumentoDB.toFixed(2)}); usato il valore calcolato, document.amount è ` +
                    `probabilmente da ricalcolare a monte`
                );
            }

            // ________ Dati Generali Documento ________

            const datiGeneraliDocumento = {
                TipoDocumento: tipoDocumento,
                Divisa: "EUR",
                Data: this.toISODate(document.documentDate),
                Numero: this.required(document.documentNumber, "Numero documento non impostato"),
                ...(scontoMaggiorazioneDocumento.length > 0 ? { ScontoMaggiorazione: scontoMaggiorazioneDocumento } : {}),
                ImportoTotaleDocumento: totaleScontato.toFixed(2),
                ...(document.note ? { Causale: [ this.truncate(document.note, 200) ] } : {})
            };

            const datiFattureCollegate =
                (document.documentArea === "credit_note" && document.refDocument && document.refDocument.documentNumber)
                    ? [{
                        IdDocumento: document.refDocument.documentNumber,
                        ...(document.refDocument.documentDate ? { Data: this.toISODate(document.refDocument.documentDate) } : {})
                    }]
                    : undefined;

            // ________ Dati Pagamento ________

            const datiPagamento = documentPayments.length > 0 ? [{
                CondizioniPagamento: documentPayments.length > 1 ? "TP01" : "TP02",
                DettaglioPagamento: documentPayments.map(payment => ({
                    ModalitaPagamento: this.resolveModalitaPagamento(payment.paymentType),
                    ImportoPagamento: this.toAmount2(payment.amount)
                }))
            }] : undefined;

            // ________ Costruzione XML ________

            const root = create({
                "p:FatturaElettronica": {
                    "@xmlns:ds": NAMESPACE_DS,
                    "@xmlns:xsi": NAMESPACE_XSI,
                    "@xmlns:p": NAMESPACE_FATTURE,
                    "@xsi:schemaLocation": `${NAMESPACE_FATTURE} Schema_VFPR12.xsd`,
                    "@versione": "FPR12",
                    "@SistemaEmittente": SISTEMA_EMITTENTE,
                    FatturaElettronicaHeader: {
                        DatiTrasmissione: datiTrasmissione,
                        CedentePrestatore: cedentePrestatore,
                        CessionarioCommittente: cessionarioCommittente
                    },
                    FatturaElettronicaBody: {
                        DatiGenerali: {
                            DatiGeneraliDocumento: datiGeneraliDocumento,
                            ...(datiFattureCollegate ? { DatiFattureCollegate: datiFattureCollegate } : {})
                        },
                        DatiBeniServizi: {
                            DettaglioLinee: dettaglioLinee,
                            DatiRiepilogo: datiRiepilogo
                        },
                        ...(datiPagamento ? { DatiPagamento: datiPagamento } : {})
                    }
                }
            });

            xml = root.end({ prettyPrint: true });

        }catch(e: any){
            throw e;
        }

        return xml;
    }

    /**
     * Ensures a required string value is present, throwing a descriptive error otherwise.
     */
    private required(value: string | undefined | null, message: string): string {
        if(!value || value.trim().length === 0){
            throw new Error(message);
        }
        return value.trim();
    }

    /**
     * Validates and normalizes a 5-digit Italian CAP (postal code).
     */
    private requiredCap(value: string | undefined | null, context: string): string {
        const cap = (value || "").trim();
        if(!/^\d{5}$/.test(cap)){
            throw new Error(`CAP non valido per il ${context}: "${value}"`);
        }
        return cap;
    }

    /**
     * Truncates a string to the given max length, falling back to a placeholder when empty.
     */
    private truncate(value: string | undefined | null, maxLength: number, fallback = "N/A"): string {
        const v = (value || "").trim();
        if(v.length === 0){
            return fallback;
        }
        return v.length > maxLength ? v.substring(0, maxLength) : v;
    }

    /**
     * Builds an <Anagrafica> block, preferring the business name over first/last name.
     */
    private buildAnagrafica(businessName?: string, firstname?: string, lastname?: string): Record<string, string> {
        if(businessName && businessName.trim().length > 0){
            return { Denominazione: this.truncate(businessName, 80) };
        }
        return {
            Nome: this.truncate(firstname, 60),
            Cognome: this.truncate(lastname, 60)
        };
    }

    /**
     * Resolves CodiceDestinatario / PECDestinatario according to SDI rules for fatture verso privati (FPR12):
     * a valid 7-character SDI channel code takes precedence, otherwise "0000000" plus an optional PEC.
     */
    private resolveDestinatario(sdiCode?: string, pec?: string): { codiceDestinatario: string, pecDestinatario?: string } {
        const code = (sdiCode || "").trim().toUpperCase();
        if(code.length === 7){
            return { codiceDestinatario: code };
        }
        return {
            codiceDestinatario: "0000000",
            pecDestinatario: pec && pec.trim().length > 0 ? pec.trim() : undefined
        };
    }

    /**
     * Payment codes are already stored as SDI <ModalitaPagamento> values (e.g. "MP01"); falls back to
     * MP01 (contanti) only if the stored value is missing or malformed.
     */
    private resolveModalitaPagamento(paymentType?: string): string {
        if(paymentType && /^MP\d{2}$/.test(paymentType)){
            return paymentType;
        }
        return "MP01";
    }

    private toISODate(value?: string | Date): string {
        if(!value){
            throw new Error("Data non impostata");
        }
        if(value instanceof Date){
            return value.toISOString().substring(0, 10);
        }
        return value.substring(0, 10);
    }

    /**
     * TypeORM returns "decimal" columns as strings at runtime (to avoid precision loss), even
     * though the entity fields are typed as `number` in TypeScript. Every numeric helper below
     * goes through this to coerce reliably regardless of what actually comes back from the driver.
     */
    private toNumber(value?: number | string | null): number {
        if(value === null || value === undefined){
            return 0;
        }
        const n = typeof value === "number" ? value : parseFloat(value);
        return Number.isFinite(n) ? n : 0;
    }

    private toAmount2(value?: number | string | null): string {
        return this.toNumber(value).toFixed(2);
    }

    private toAmount8(value?: number | string | null): string {
        return this.toNumber(value).toFixed(2);
    }

    private toRate(value?: number | string | null): string {
        return this.toNumber(value).toFixed(2);
    }

    private toQuantity(value?: number | string | null): string {
        return Math.abs(this.toNumber(value)).toFixed(2);
    }

    /**
     * Rounds to 2 decimals via string formatting to avoid classic binary floating-point
     * artifacts (e.g. 1.005 -> 1.00 instead of the "expected" 1.01) creeping into the checks below.
     */
    private round2(value?: number | string | null): number {
        return Number(this.toNumber(value).toFixed(2));
    }

    /**
     * Approximates the rounding tolerance SDI accepts when checking that <Imposta> matches
     * <ImponibileImporto> * <AliquotaIVA> / 100 for a riepilogo block: 1 cent for every 2 detail
     * lines aggregated into it, with a floor of 1 cent.
     */
    private groupTolerance(lineCount: number): number {
        return Math.max(0.01, this.round2(Math.round(lineCount / 2) * 0.01));
    }

    /**
     * Formats the <Importo> of a per-line <ScontoMaggiorazione>, using the fewest decimals (starting
     * from the usual 2 "cash" decimals, up to the 8 allowed by Amount8DecimalType) that still reconcile
     * (PrezzoUnitario ± Importo) * Quantita back to PrezzoTotale within half a cent. Forcing 8 decimals
     * unconditionally (e.g. "9.02000000" for a plain quantity-1 line) is technically valid but reads as
     * a suspicious, not-quite-right amount; extra precision is only actually needed when the quantity
     * would otherwise amplify a 2-decimal rounding of Importo into a visible mismatch on PrezzoTotale.
     */
    private formatScontoImporto(delta: number, prezzoUnitario: number, quantita: number, prezzoTotaleAtteso: number): string {
        const segno = delta > 0 ? 1 : -1;
        for(let decimali = 2; decimali <= 8; decimali++){
            const importo = Number(Math.abs(delta).toFixed(decimali));
            const ricostruito = this.round2((prezzoUnitario - segno * importo) * quantita);
            if(Math.abs(ricostruito - prezzoTotaleAtteso) < 0.005){
                return importo.toFixed(decimali);
            }
        }
        return Math.abs(delta).toFixed(8);
    }

    /**
     * Derives <RiferimentoNormativo> for a riepilogo block with aliquota zero from the same
     * descriptive text used for the "nature" dropdown (`natures` in constants.ts), stripping the
     * leading "N.. - " code prefix since <Natura> already carries the code itself.
     */
    private riferimentoNormativoPerNatura(codiceNatura: string): string {
        const entry = natures.find(n => n.id === codiceNatura);
        const title = entry?.title || codiceNatura;
        const separatorIndex = title.indexOf(" - ");
        const testo = separatorIndex >= 0 ? title.substring(separatorIndex + 3) : title;
        return this.truncate(testo, 100, codiceNatura);
    }

    /**
     * Check if has valid data for invoice or credit note
     * @param payload
     */
    async hasValidDataForInvoiceOrCreditNote(payload: DocumentCreateDto): Promise<ResponseDto>{

        const response: ResponseDto = new ResponseDto();
        response.data = { "errorVerboseIT": null };

        try{

            response.success = true;

            if(payload.documentArea !== "invoice" && payload.documentArea !== "credit_note"){
                return response;
            }

            const errors: string[] = [];

            // Limiti di lunghezza imposti dallo XSD FatturaPA (v1.2.3): un campo che li supera oggi
            // verrebbe troncato silenziosamente da `truncate()` in createXML, alterando senza avviso
            // il contenuto della fattura legale. Qui lo segnaliamo esplicitamente prima che accada.
            const checkMaxLength = (value: string | undefined | null, max: number, label: string) => {
                const v = (value || "").trim();
                if(v.length > max){
                    errors.push(`${label} supera la lunghezza massima di ${max} caratteri (attuale: ${v.length})`);
                }
            };

            const checkLengthRange = (value: string | undefined | null, min: number, max: number, label: string) => {
                const v = (value || "").trim();
                if(v.length > 0 && (v.length < min || v.length > max)){
                    errors.push(`${label} deve avere una lunghezza tra ${min} e ${max} caratteri (attuale: "${v}", ${v.length})`);
                }
            };

            const checkExactLength = (value: string | undefined | null, len: number, label: string) => {
                const v = (value || "").trim();
                if(v.length > 0 && v.length !== len){
                    errors.push(`${label} deve avere lunghezza esattamente ${len} caratteri (attuale: "${v}")`);
                }
            };

            // ________ Negozio emittente (Cedente/Prestatore) ________

            if(!payload.idStore){
                errors.push("Punto vendita emittente non specificato");
            }else{

                const store =
                    await this.entityManager.findOne(
                        StoreEntity,
                        {
                            where: {
                                id: payload.idStore
                            }
                        }
                    );

                if(!store){
                    errors.push("Punto vendita emittente non trovato");
                }else{

                    if(!store.vatNumber || store.vatNumber.trim().length === 0){
                        errors.push("Partita IVA del punto vendita emittente non impostata");
                    }
                    checkMaxLength(store.vatNumber, 28, "Partita IVA del punto vendita emittente");

                    if(!store.fiscalRegim || !/^RF\d{2}$/.test(store.fiscalRegim.trim().toUpperCase())){
                        errors.push(`Regime fiscale del punto vendita emittente non valido o non impostato ("${store.fiscalRegim ?? ""}")`);
                    }

                    checkLengthRange(store.fiscalCode, 11, 16, "Codice Fiscale del punto vendita emittente");

                    if(!store.businessName?.trim() && !store.title?.trim()){
                        errors.push("Ragione sociale del punto vendita emittente non impostata");
                    }
                    checkMaxLength(store.businessName || store.title, 80, "Ragione sociale del punto vendita emittente");

                    if(!store.address || store.address.trim().length === 0){
                        errors.push("Indirizzo del punto vendita emittente non impostato");
                    }
                    checkMaxLength(store.address, 60, "Indirizzo del punto vendita emittente");

                    if(!/^\d{5}$/.test((store.zipCode || "").trim())){
                        errors.push(`CAP del punto vendita emittente non valido ("${store.zipCode ?? ""}")`);
                    }

                    if(!store.city || store.city.trim().length === 0){
                        errors.push("Comune del punto vendita emittente non impostato");
                    }
                    checkMaxLength(store.city, 60, "Comune del punto vendita emittente");

                    checkExactLength(store.province, 2, "Provincia del punto vendita emittente");
                    checkExactLength(store.countryCode, 2, "Nazione del punto vendita emittente");

                }

            }

            // ________ Cliente (Cessionario/Committente) ________

            if(!payload.idCustomer){
                errors.push("Cliente non specificato");
            }else{

                const customer =
                    await this.entityManager.findOne(
                        CustomerEntity,
                        {
                            where: {
                                id: payload.idCustomer
                            }
                        }
                    );

                if(!customer){
                    errors.push("Cliente non trovato");
                }else{

                    const hasVatNumber = !!customer.vatNumber && customer.vatNumber.trim().length > 0;
                    const hasFiscalCode = !!customer.fiscalCode && customer.fiscalCode.trim().length > 0;

                    if(!hasVatNumber && !hasFiscalCode){
                        errors.push("Il cliente deve avere almeno una Partita IVA o un Codice Fiscale");
                    }
                    checkMaxLength(customer.vatNumber, 28, "Partita IVA del cliente");
                    checkLengthRange(customer.fiscalCode, 11, 16, "Codice Fiscale del cliente");

                    const hasBusinessName = !!customer.businessName && customer.businessName.trim().length > 0;
                    const hasFirstAndLastName = !!customer.firstname?.trim() && !!customer.lastname?.trim();

                    if(!hasBusinessName && !hasFirstAndLastName){
                        errors.push("Anagrafica cliente incompleta: specificare Ragione Sociale oppure Nome e Cognome");
                    }
                    checkMaxLength(customer.businessName, 80, "Ragione sociale del cliente");
                    checkMaxLength(customer.firstname, 60, "Nome del cliente");
                    checkMaxLength(customer.lastname, 60, "Cognome del cliente");

                    if(!customer.address || customer.address.trim().length === 0){
                        errors.push("Indirizzo del cliente non impostato");
                    }
                    checkMaxLength(customer.address, 60, "Indirizzo del cliente");

                    if(!/^\d{5}$/.test((customer.zipCode || "").trim())){
                        errors.push(`CAP del cliente non valido ("${customer.zipCode ?? ""}")`);
                    }

                    if(!customer.city || customer.city.trim().length === 0){
                        errors.push("Comune del cliente non impostato");
                    }
                    checkMaxLength(customer.city, 60, "Comune del cliente");

                    checkExactLength(customer.province, 2, "Provincia del cliente");

                }

            }

            // ________ Righe documento ________

            if(!payload.rows || payload.rows.length === 0){
                errors.push("Il documento non contiene righe");
            }else{

                payload.rows.forEach((row, index) => {

                    const rate = this.toNumber(row.rate);

                    if(rate === 0 && (!row.nature || row.nature.trim().length === 0)){
                        errors.push(`Riga ${index + 1}: aliquota IVA pari a zero richiede la valorizzazione della Natura`);
                    }

                    checkMaxLength(row.title, 1000, `Riga ${index + 1}: descrizione`);

                });

            }

            // ________ Dati generali documento ________

            checkMaxLength(payload.note, 200, "Causale documento");

            if(errors.length > 0){
                response.data.errorVerboseIT = errors.join("; ");
            }

        }catch(e: any){
            response.error = e.message;
        }

        return response;
    }

}
