"""Température de l'exploitation en cours d'exercice — moteur de calcul.

Méthode décrite sur https://lesdeuxrecoltes.fr/tresorerie/ebe-en-cours-d-annee/

Exécuté dans le navigateur du visiteur par l'outil en ligne : aucune donnée
n'est envoyée. Entrées, au format XLSX ou CSV : soit une balance générale à
date (et, en option, des balances analytiques), soit les grands livres
analytiques des comptes 6 et 7, un fichier par exercice. Avec les grands
livres, l'outil calcule n'importe quelle période : cumul de l'exercice ou
12 mois glissants, mois par mois, et le bénéfice brut. Le
format de référence est celui des balances exportées d'Isacompta ; les
colonnes sont lues par leur nom, pas par leur position.

Principes de calcul :
  - EBE de gestion = produits d'exploitation (70 à 75, sauf 757 cessions
    d'immobilisations) - charges d'exploitation (60 à 65), hors rémunération
    du travail de l'exploitant (644, ou libellé « rémunération ... travail /
    exploitant ») ; ni financier (66, 76), ni exceptionnel (67, 77) ;
  - la balance ne contient ni stocks, ni amortissements, ni écritures de
    clôture : le chiffre se compare d'une année sur l'autre à la même date,
    pas au bilan ;
  - balance analytique : chaque code activité est rangé dans un atelier
    d'après son premier chiffre (codes à quatre chiffres) ; les cultures
    fourragères sont rangées dans l'élevage, qui les consomme ;
  - lait : litres = quantités en litres des comptes 7020 ; le coût
    alimentaire acheté est détaillé par code activité pour que l'outil laisse
    choisir les codes du troupeau laitier.
"""
import base64
import csv
import io
import json
import re
import unicodedata
import zipfile
import xml.etree.ElementTree as ET
from datetime import date, datetime, timedelta


class ErreurExport(Exception):
    """Fichier illisible ou incomplet : message destiné à l'exploitant."""


# ---------------------------------------------------------------- lecture

def _norm(t):
    t = unicodedata.normalize("NFKD", str(t if t is not None else "")).encode("ascii", "ignore").decode()
    return " ".join(t.lower().replace(".", " ").replace("_", " ").split())


_NS = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
_REL = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"


def _col(ref):
    n = 0
    for ch in ref:
        if ch.isalpha():
            n = n * 26 + ord(ch.upper()) - 64
        else:
            break
    return n - 1


def _lire_xlsx(octets):
    try:
        z = zipfile.ZipFile(io.BytesIO(octets))
    except zipfile.BadZipFile:
        raise ErreurExport("le fichier ne s'ouvre pas comme un classeur Excel (.xlsx).")
    noms = set(z.namelist())
    partages = []
    if "xl/sharedStrings.xml" in noms:
        for si in ET.fromstring(z.read("xl/sharedStrings.xml")).findall("m:si", _NS):
            partages.append("".join(t.text or "" for t in si.iter("{%s}t" % _NS["m"])))
    feuille = "xl/worksheets/sheet1.xml"
    try:
        wb = ET.fromstring(z.read("xl/workbook.xml"))
        rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
        premiere = wb.find("m:sheets/m:sheet", _NS)
        cible = {r.get("Id"): r.get("Target") for r in rels}[premiere.get(_REL)]
        cible = cible.lstrip("/")
        feuille = cible if cible.startswith("xl/") else "xl/" + cible
    except Exception:
        pass
    if feuille not in noms:
        raise ErreurExport("le classeur ne contient pas de feuille lisible.")
    lignes = []
    for row in ET.fromstring(z.read(feuille)).iter("{%s}row" % _NS["m"]):
        valeurs = {}
        for i, c in enumerate(row.findall("m:c", _NS)):
            ref, t = c.get("r"), c.get("t")
            j = _col(ref) if ref else i
            v = c.find("m:v", _NS)
            if t == "s" and v is not None:
                val = partages[int(v.text)]
            elif t == "inlineStr":
                val = "".join(x.text or "" for x in c.iter("{%s}t" % _NS["m"]))
            elif t == "b" and v is not None:
                val = v.text == "1"
            elif t in ("str", "e"):
                val = v.text if v is not None else None
            elif v is not None and v.text is not None:
                try:
                    val = float(v.text)
                except ValueError:
                    val = v.text
            else:
                val = None
            valeurs[j] = val
        if valeurs:
            lignes.append([valeurs.get(k) for k in range(max(valeurs) + 1)])
    return lignes


def _lire_csv(octets):
    for enc in ("utf-8-sig", "cp1252"):
        try:
            texte = octets.decode(enc)
            break
        except UnicodeDecodeError:
            continue
    echantillon = texte[:5000]
    sep = max([";", ",", "\t"], key=echantillon.count)
    return [ligne for ligne in csv.reader(io.StringIO(texte), delimiter=sep)]


def _nombre(v):
    """Montant lu tel qu'il est écrit : 1 234,56 / 1,234.56 / -1234.5 / (12,00)."""
    if v is None or v is True or v is False:
        return 0.0
    if isinstance(v, (int, float)):
        return float(v)
    s = str(v).strip().replace(" ", "").replace(" ", "").replace(" ", "").replace("€", "")
    if not s:
        return 0.0
    neg = s.startswith("(") and s.endswith(")")
    s = s.strip("()")
    if "," in s and "." in s:
        s = s.replace(",", "") if s.rfind(".") > s.rfind(",") else s.replace(".", "").replace(",", ".")
    elif "," in s:
        s = s.replace(",", "") if re.fullmatch(r"-?\d{1,3}(,\d{3}){2,}", s) else s.replace(",", ".")
    elif re.fullmatch(r"-?\d{1,3}(\.\d{3}){2,}", s):
        s = s.replace(".", "")
    try:
        x = float(s)
    except ValueError:
        return 0.0
    return -x if neg else x


def _date(v):
    """Date d'une cellule : texte jj/mm/aa(aa), date ISO ou numéro de série Excel."""
    if v is None or v == "":
        return None
    if isinstance(v, float):
        if 20000 < v < 80000:
            return date(1899, 12, 30) + timedelta(days=int(v))
        return None
    s = str(v).strip()
    m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})(?:[T ].*)?", s)
    if m:
        try:
            return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
        except ValueError:
            return None
    m = re.search(r"(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})", s)
    if m:
        j, mo, a = int(m.group(1)), int(m.group(2)), int(m.group(3))
        a = a + 2000 if a < 100 else a
        try:
            return date(a, mo, j)
        except ValueError:
            return None
    m = re.search(r"(\d{4})-(\d{2})-(\d{2})", s)
    if m:
        try:
            return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
        except ValueError:
            return None
    return None


def _date_du_nom(nom):
    """« balance générale 011026.xlsx » -> 1er octobre 2026."""
    m = re.findall(r"(?<!\d)(\d{6}|\d{8})(?!\d)", nom or "")
    for d in reversed(m):
        j, mo = int(d[:2]), int(d[2:4])
        a = int(d[4:]) + (2000 if len(d) == 6 else 0)
        try:
            return date(a, mo, j)
        except ValueError:
            continue
    return None


def _compte(v):
    if isinstance(v, float):
        v = str(int(v)) if v == int(v) else str(v)
    s = re.sub(r"\s", "", str(v or ""))
    return s if re.fullmatch(r"\d{3,12}", s) else None


def _code(v):
    if isinstance(v, float) and v == int(v):
        v = int(v)
    return str(v).strip() if v is not None else ""


# ------------------------------------------------------ reconnaissance

def _entete(lignes):
    for i, ligne in enumerate(lignes[:30]):
        n = [_norm(c) for c in ligne]
        if "compte" in n and any(x.startswith("solde") or x in ("debit", "credit") for x in n):
            return i, n
    raise ErreurExport("aucune ligne d'en-tête avec les colonnes « Compte » et « Solde » n'a été trouvée.")


def _cherche(n, *noms, debut=False):
    for k, x in enumerate(n):
        for nom in noms:
            if x == nom or (debut and x.startswith(nom)):
                return k
    return None


def lire_fichier(nom, octets):
    lignes = _lire_xlsx(octets) if octets[:2] == b"PK" else _lire_csv(octets)
    if not lignes:
        raise ErreurExport("le fichier est vide.")
    for k, ligne in enumerate(lignes[:30]):
        nk = [_norm(c) for c in ligne]
        if any(x.startswith("date de piece") or x == "date piece" for x in nk) and any("debit" == x for x in nk):
            return _lire_grand_livre(nom, lignes[k + 1:], nk)
    h, n = _entete(lignes)
    dessus = lignes[h - 1] if h > 0 else []
    i_compte = n.index("compte")
    i_lib = _cherche(n, "libelle", "intitule", "libelle compte", debut=True)
    i_act = _cherche(n, "activite", "code activite", "activites")
    if i_act is not None:
        return _lire_analytique(nom, lignes[h + 1:], n, dessus, i_compte, i_lib, i_act)
    return _lire_generale(nom, lignes[h + 1:], n, dessus, i_compte, i_lib)


def _date_colonne(dessus, k, nom):
    if dessus and k is not None and k < len(dessus):
        d = _date(dessus[k])
        if d:
            return d
    return None


def _lire_generale(nom, lignes, n, dessus, i_compte, i_lib):
    colonnes = []
    for k, x in enumerate(n):
        if x in ("solde", "solde p", "solde n", "solde periode"):
            colonnes.insert(0, (0, k))
        elif re.fullmatch(r"solde (p|n) ?-? ?1", x):
            colonnes.append((1, k))
        elif re.fullmatch(r"solde (p|n) ?-? ?2", x):
            colonnes.append((2, k))
    debit = credit = None
    if not any(r == 0 for r, _ in colonnes):
        debit = _cherche(n, "debit", "solde debit", "total debit")
        credit = _cherche(n, "credit", "solde credit", "total credit")
        if debit is None or credit is None:
            raise ErreurExport("la balance générale n'a ni colonne « Solde » ni colonnes « Débit » et « Crédit ».")
    colonnes.sort()
    rangs = sorted({r for r, _ in colonnes} | ({0} if debit is not None else set()))
    comptes = []
    for ligne in lignes:
        c = _compte(ligne[i_compte] if i_compte < len(ligne) else None)
        if not c or c[0] not in "67":
            continue
        lib = str(ligne[i_lib]).strip() if i_lib is not None and i_lib < len(ligne) and ligne[i_lib] is not None else ""
        soldes = {}
        for r, k in colonnes:
            soldes[r] = _nombre(ligne[k] if k < len(ligne) else None)
        if debit is not None:
            soldes[0] = _nombre(ligne[debit] if debit < len(ligne) else None) - _nombre(ligne[credit] if credit < len(ligne) else None)
        comptes.append({"compte": c, "libelle": lib, "soldes": soldes})
    if not comptes:
        raise ErreurExport("la balance générale ne contient aucun compte de charges (6) ou de produits (7).")
    dates = {}
    for r, k in colonnes:
        dates[r] = _date_colonne(dessus, k, nom)
    if not dates.get(0):
        dates[0] = _date_du_nom(nom)
    if not dates.get(0):
        for v in dessus or []:
            dates[0] = dates.get(0) or _date(v)
    for r in rangs:
        if not dates.get(r) and dates.get(0):
            try:
                dates[r] = dates[0].replace(year=dates[0].year - r)
            except ValueError:
                dates[r] = dates[0] - timedelta(days=365 * r)
    return {"type": "generale", "nom": nom, "rangs": rangs, "dates": dates, "comptes": comptes}


def _lire_analytique(nom, lignes, n, dessus, i_compte, i_lib, i_act):
    i_solde = _cherche(n, "solde", "montant", debut=True)
    if i_solde is None:
        raise ErreurExport("la balance analytique n'a pas de colonne « Solde ».")
    i_qte = _cherche(n, "quantite 1", "quantite", "qte", "qte 1")
    i_u = _cherche(n, "u", "unite", "u1", "unite 1")
    lignes_ok = []
    for ligne in lignes:
        c = _compte(ligne[i_compte] if i_compte < len(ligne) else None)
        if not c or c[0] not in "67":
            continue
        get = lambda k: ligne[k] if k is not None and k < len(ligne) else None
        lignes_ok.append({
            "compte": c, "code": _code(get(i_act)),
            "libelle": str(get(i_lib) or "").strip(),
            "solde": _nombre(get(i_solde)),
            "qte": _nombre(get(i_qte)), "unite": str(get(i_u) or "").strip().upper(),
        })
    if not lignes_ok:
        raise ErreurExport("la balance analytique ne contient aucun compte de charges (6) ou de produits (7).")
    d = _date_colonne(dessus, i_solde, nom)
    for v in dessus or []:
        d = d or _date(v)
    return {"type": "analytique", "nom": nom, "date": d or _date_du_nom(nom), "lignes": lignes_ok}


# ------------------------------------------------------------- calculs

# Vrai quand le plan comptable range la rémunération du travail des associés en 6413
# (un compte 6413 au moins y porte un libellé de rémunération du travail) : tous les 6413 sont alors écartés.
_REMU_6413 = False


def _libelle_remu(libelle):
    l = _norm(libelle)
    return "remun" in l and any(m in l for m in ("travail", "exploitant", "gerant", "associ"))


def _exclu(compte, libelle):
    return (compte.startswith("644") or (_REMU_6413 and compte.startswith("6413"))
            or _libelle_remu(libelle))


def _produit_expl(c):
    return c[:2] in ("70", "71", "72", "73", "74", "75") and not c.startswith("757")


def _charge_expl(c):
    return c[:2] in ("60", "61", "62", "63", "64", "65")


def _signe_produits(comptes, cle):
    """Produits au crédit (soldes négatifs) dans la plupart des exports ; certains les donnent en positif."""
    p = [x for x in comptes if x["compte"][0] == "7" and cle(x)]
    neg = sum(1 for x in p if cle(x) < 0)
    return -1 if neg >= len(p) - neg else 1


FAMILLES_P = [("701", "Ventes de produits végétaux"), ("702", "Ventes de produits animaux"),
              ("703", "Ventes de produits transformés"), ("704", "Ventes d'animaux"),
              ("70", "Autres ventes, travaux et activités annexes"), ("71", "Production stockée"),
              ("72", "Production immobilisée"), ("74", "Aides et subventions d'exploitation"),
              ("75", "Autres produits de gestion")]
FAMILLES_C = [("6011", "Engrais et amendements"), ("6012", "Semences et plants"),
              ("6013", "Produits de défense des végétaux"), ("6014", "Aliments du bétail"),
              ("6015", "Produits vétérinaires"), ("601", "Autres approvisionnements"),
              ("602", "Carburants et fournitures"), ("605", "Travaux par tiers"),
              ("606", "Achats non stockés (énergie, eau, petit matériel)"), ("60", "Autres achats"),
              ("613", "Fermages et locations"), ("615", "Entretien et réparations"), ("616", "Assurances"),
              ("61", "Autres services extérieurs"), ("62", "Honoraires, transports, cotisations, services"),
              ("63", "Impôts et taxes"), ("64", "Personnel et charges sociales"), ("65", "Autres charges de gestion")]


def _famille(c, familles):
    for pre, lib in familles:
        if c.startswith(pre):
            return lib
    return "Autres"


def calculer_generale(g):
    rangs = g["rangs"]
    sp = {r: _signe_produits(g["comptes"], lambda x, r=r: x["soldes"].get(r, 0)) for r in rangs}
    periodes = {r: {"produits": 0.0, "charges": 0.0, "lait_7020": 0.0} for r in rangs}
    familles, detail, exclus, hors = {}, [], [], {r: 0.0 for r in rangs}
    for x in g["comptes"]:
        c, lib = x["compte"], x["libelle"]
        if _stock(c) or _reint(lib):
            exclus.append({"compte": c, "libelle": lib,
                           "motif": "variation de stocks (clôture)" if _stock(c) else "réintégration de clôture",
                           "montants": [round(x["soldes"].get(r, 0), 2) for r in rangs]})
            continue
        if _exclu(c, lib) and c[0] == "6":
            exclus.append({"compte": c, "libelle": lib, "motif": "rémunération du travail (exploitant, associés)",
                           "montants": [round(x["soldes"].get(r, 0), 2) for r in rangs]})
            continue
        if c[0] == "7":
            m = {r: x["soldes"].get(r, 0) * sp[r] for r in rangs}
        else:
            m = {r: x["soldes"].get(r, 0) for r in rangs}
        if _produit_expl(c):
            sens, fam = "produit", _famille(c, FAMILLES_P)
        elif _charge_expl(c):
            sens, fam = "charge", _famille(c, FAMILLES_C)
        else:
            motif = ("cession d'immobilisation" if c.startswith("757") else
                     "amortissements et provisions" if c.startswith("68") else
                     "reprises et transferts de charges" if c[:2] in ("78", "79") else
                     "impôt" if c.startswith("69") else
                     "financier" if c[:2] in ("66", "76") else "exceptionnel")
            exclus.append({"compte": c, "libelle": lib, "motif": motif,
                           "montants": [round(m[r], 2) for r in rangs]})
            continue
        for r in rangs:
            periodes[r]["produits" if sens == "produit" else "charges"] += m[r]
            if c.startswith("7020"):
                periodes[r]["lait_7020"] += m[r]
        f = familles.setdefault((sens, fam), {"sens": sens, "famille": fam, "montants": {r: 0.0 for r in rangs}})
        for r in rangs:
            f["montants"][r] += m[r]
        detail.append({"compte": c, "libelle": lib, "sens": sens, "famille": fam,
                       "montants": [round(m[r], 2) for r in rangs]})
    sortie = []
    for r in rangs:
        p = periodes[r]
        ebe = p["produits"] - p["charges"]
        sortie.append({"rang": r, "date": g["dates"].get(r).isoformat() if g["dates"].get(r) else None,
                       "produits": round(p["produits"], 2), "charges": round(p["charges"], 2),
                       "ebe": round(ebe, 2), "taux": round(ebe / p["produits"], 4) if p["produits"] else None,
                       "lait_7020": round(p["lait_7020"], 2)})
    ordre = {lib: i for i, (_, lib) in enumerate(FAMILLES_P + FAMILLES_C)}
    fam_sortie = sorted(familles.values(), key=lambda f: (f["sens"] != "produit", ordre.get(f["famille"], 99)))
    for f in fam_sortie:
        f["montants"] = [round(f["montants"][r], 2) for r in rangs]
    detail.sort(key=lambda d: (d["sens"] != "produit", d["compte"]))
    return {"periodes": sortie, "familles": fam_sortie, "comptes": detail, "exclus": exclus}


ATELIERS = ["Cultures de vente", "Élevage", "Activités annexes", "Structure", "Non affecté", "Autres codes"]


def atelier(code):
    if code == "":
        return "Non affecté"
    if code.upper() == "CS":
        return "Structure"
    if re.fullmatch(r"\d{4}", code):
        d = code[0]
        if d in "123":
            return "Cultures de vente"
        if d in "456":
            return "Élevage"
        return "Activités annexes"
    if re.fullmatch(r"[789]", code):
        return "Activités annexes"
    return "Autres codes"


def _lib_code(libelles):
    vus = {}
    for l in libelles:
        if " - " in l:
            s = l.split(" - ", 1)[1].strip()
            vus[s] = vus.get(s, 0) + 1
    return max(vus, key=vus.get) if vus else ""


def calculer_analytique(a):
    lignes = a["lignes"]
    sp = _signe_produits([{"compte": x["compte"], "s": x["solde"]} for x in lignes], lambda x: x["s"])
    codes = {}
    lait_litres, lait_ca, codes_lait = 0.0, 0.0, set()
    for x in lignes:
        if x["compte"].startswith("7020") and x["unite"] in ("L", "LT", "LITRE", "LITRES") and x["qte"]:
            codes_lait.add(x["code"])
            lait_litres += abs(x["qte"])
    exclus = 0.0
    for x in lignes:
        c, code = x["compte"], x["code"]
        if _stock(c) or _reint(x["libelle"].split(" - ")[0]):
            continue
        if _exclu(c, x["libelle"]) and c[0] == "6":
            exclus += x["solde"]
            continue
        if not (_produit_expl(c) or _charge_expl(c)):
            continue
        k = codes.setdefault(code, {"code": code, "atelier": atelier(code), "libelles": [],
                                    "produits": 0.0, "charges": 0.0, "aliments": 0.0, "lait": False})
        k["libelles"].append(x["libelle"])
        if c[0] == "7":
            k["produits"] += x["solde"] * sp
            if c.startswith("7020") and code in codes_lait:
                lait_ca += x["solde"] * sp
        else:
            k["charges"] += x["solde"]
            l = _norm(x["libelle"].split(" - ")[0])
            achat_alim = c.startswith("6014") or (c.startswith("60") and "fourrage" in l)
            if achat_alim and "veau" not in l and "genisse" not in l:
                k["aliments"] += x["solde"]
        if code in codes_lait:
            k["lait"] = True
    sortie = []
    for k in codes.values():
        lib = _lib_code(k.pop("libelles"))
        k["libelle"] = "Charges de structure" if k["code"].upper() == "CS" else lib
        for f in ("produits", "charges", "aliments"):
            k[f] = round(k[f], 2)
        k["fourrage"] = k["atelier"] == "Élevage" and k["code"][:1] == "4"
        sortie.append(k)
    sortie.sort(key=lambda k: (ATELIERS.index(k["atelier"]), k["code"]))
    return {"date": a["date"].isoformat() if a["date"] else None, "nom": a["nom"],
            "codes": sortie, "lait_litres": round(lait_litres, 1), "lait_ca": round(lait_ca, 2),
            "exclu_remuneration": round(exclus, 2)}


# ------------------------------------------------------- grands livres

def _lire_grand_livre(nom, lignes, n):
    def col(*debuts):
        for k, x in enumerate(n):
            if any(x == d or x.startswith(d) for d in debuts):
                return k
        return None
    i_c = col("n de compte", "numero de compte", "compte")
    i_lc = col("libelle du compte", "libelle compte")
    i_d = col("date de piece", "date piece")
    i_a = col("code activite")
    i_la = col("libelle de l'activite", "libelle de l activite", "libelle activite")
    i_lm = col("libelle mouvement")
    i_db, i_cr = col("debit"), col("credit")
    i_q = col("quantite 1")
    i_u = [k for k, x in enumerate(n) if x.startswith("unite quantite")]
    if None in (i_c, i_d, i_db, i_cr):
        raise ErreurExport("grand livre incomplet : il faut les colonnes « N° de compte », « Date de pièce », « Débit » et « Crédit ».")
    get = lambda l, k: l[k] if k is not None and k < len(l) else None
    ecr = []
    for l in lignes:
        c = _compte(get(l, i_c))
        if not c or c[0] not in "67":
            continue
        d = _date(get(l, i_d))
        if not d:
            continue
        code = _code(get(l, i_a))
        if _norm(code).startswith("non aff"):
            code = ""
        u = next((str(get(l, k) or "").strip().upper() for k in i_u if str(get(l, k) or "").strip()), "")
        if not u and c.startswith("7020"):
            u = "L"
        ecr.append({"d": d, "c": c, "code": code, "lib": str(get(l, i_lc) or "").strip(),
                    "lact": str(get(l, i_la) or "").strip(), "lm": str(get(l, i_lm) or "").strip(),
                    "s": _nombre(get(l, i_db)) - _nombre(get(l, i_cr)), "q": _nombre(get(l, i_q)), "u": u})
    if not ecr:
        raise ErreurExport("le grand livre ne contient aucune écriture des comptes 6 et 7.")
    m = re.search(r"(?i)GLA?(\d{2})(\d{2})(\d{2})-(\d{6})", nom or "")
    debut = (int(m.group(1)), int(m.group(2))) if m else None
    return {"type": "grand_livre", "nom": nom, "ecritures": ecr, "debut": debut}


def _reint(lib):
    """Réintégration des cotisations personnelles passée par le comptable à la clôture."""
    return re.search(r"r.?int\.? ?charges sociales", _norm(lib)) is not None


def _cotis(c):
    """Cotisations sociales personnelles de l'exploitant : comprises dans l'EBE."""
    return c.startswith("646")


def _stock(c):
    return c.startswith("603") or c.startswith("71")


def _plus_ans(d, k):
    try:
        return d.replace(year=d.year + k)
    except ValueError:
        return d.replace(year=d.year + k, day=28)


def _debut_exercice(fin, jour, mois):
    d = date(fin.year, mois, jour)
    return d if d <= fin else date(fin.year - 1, mois, jour)


def _benef(ecr, stocks=True):
    """Passage de l'EBE au bénéfice brut, avant rémunération du travail et cotisations
    personnelles de l'exploitant. stocks=False : stocks supposés stables (écritures 603/71 ignorées)."""
    x = {"amort": 0.0, "stocks": 0.0, "remu": 0.0, "cotis": 0.0, "fin": 0.0, "exc": 0.0, "repr": 0.0, "total": 0.0}
    for e in ecr:
        c, s_ = e["c"], e["s"]
        if _reint(e["lib"]) or (_stock(c) and not stocks):
            continue
        x["total"] -= s_
        if _exclu(c, e["lib"]):
            x["remu"] += s_
        elif _cotis(c):
            x["cotis"] += s_
        elif _stock(c):
            x["stocks"] -= s_
        elif c.startswith("68"):
            x["amort"] += s_
        elif c[:2] in ("78", "79"):
            x["repr"] -= s_
        elif c[:2] in ("66", "76"):
            x["fin"] -= s_
        elif c[:2] in ("67", "77", "69") or c.startswith("757"):
            x["exc"] -= s_
    x["brut"] = x["total"] + x["remu"] + x["cotis"]
    return {k: round(v, 2) for k, v in x.items()}


def _ebe(ecr):
    p = ch = 0.0
    for e in ecr:
        c = e["c"]
        if _reint(e["lib"]) or _stock(c) or _exclu(c, e["lib"]):
            continue
        if _produit_expl(c):
            p -= e["s"]
        elif _charge_expl(c):
            ch += e["s"]
    return p - ch


def analyser_grands_livres(gls, opt):
    ecr = sorted((e for g in gls for e in g["ecritures"]), key=lambda e: e["d"])
    dmin, dmax = ecr[0]["d"], ecr[-1]["d"]
    debut = next((g["debut"] for g in gls if g["debut"]), None) or (1, int(opt.get("debut_mois") or 1))
    mode = opt.get("mode") or "cumul"
    ref = _date(opt.get("ref")) if opt.get("ref") else None
    ref = min(ref, dmax) if ref else dmax
    periodes = []
    for k in range(3):
        fin = _plus_ans(ref, -k)
        deb = _debut_exercice(fin, *debut) if mode == "cumul" else _plus_ans(fin, -1) + timedelta(days=1)
        if deb < dmin:
            break
        periodes.append((k, deb, fin))
    if not periodes:
        raise ErreurExport("les grands livres ne couvrent pas une période complète jusqu'à cette date.")
    # exercices complets : clôturés si le comptable a passé les dotations aux amortissements
    # (le stock initial, lui, est saisi dès la clôture de l'exercice précédent)
    clos, non_clos = [], []
    d = date(dmin.year - 1, debut[1], debut[0])
    while d <= dmax:
        f = _plus_ans(d, 1) - timedelta(days=1)
        if d >= dmin and f <= dmax:
            ex = [e for e in ecr if d <= e["d"] <= f]
            if any(e["c"].startswith("68") for e in ex):
                clos.append({**_benef(ex), "ebe": round(_ebe(ex), 2), "debut": d.isoformat(), "fin": f.isoformat()})
            else:
                non_clos.append({"debut": d.isoformat(), "fin": f.isoformat()})
        d = _plus_ans(d, 1)
    rangs = [k for k, _, _ in periodes]
    dans = {k: [e for e in ecr if a <= e["d"] <= b] for k, a, b in periodes}
    # balance générale reconstituée
    comptes = {}
    for k in rangs:
        for e in dans[k]:
            if _stock(e["c"]) or _reint(e["lib"]):
                continue
            x = comptes.setdefault(e["c"], {"compte": e["c"], "libelle": e["lib"], "soldes": {r: 0.0 for r in rangs}})
            x["soldes"][k] += e["s"]
    g = calculer_generale({"rangs": rangs, "dates": {k: b for k, _, b in periodes}, "comptes": list(comptes.values())})
    for p_, (k, a, b) in zip(g["periodes"], periodes):
        p_["debut"] = a.isoformat()
        # stocks réels seulement si la période est un exercice clôturé entier ; sinon supposés stables
        reel = any(x["debut"] == a.isoformat() and x["fin"] == b.isoformat() for x in clos)
        p_["benefice"] = {**_benef(dans[k], stocks=reel), "stocks_reels": reel}
    # balances analytiques reconstituées
    ana = []
    for k, a, b in periodes:
        lig = {}
        for e in dans[k]:
            if _stock(e["c"]) or _reint(e["lib"]):
                continue
            x = lig.setdefault((e["c"], e["code"]), {"compte": e["c"], "code": e["code"],
                               "libelle": e["lib"] + (" - " + e["lact"] if e["lact"] else ""),
                               "solde": 0.0, "qte": 0.0, "unite": e["u"]})
            x["solde"] += e["s"]
            x["qte"] += e["q"]
            x["unite"] = x["unite"] or e["u"]
        ana.append(calculer_analytique({"date": b, "nom": "", "lignes": list(lig.values())}))
    # mois par mois
    mois = {}
    for e in ecr:
        mk = e["d"].strftime("%Y-%m")
        x = mois.setdefault(mk, {"mois": mk, "ebe": 0.0, "litres": 0.0, "lait": 0.0, "_e": []})
        x["_e"].append(e)
    mens = []
    for mk in sorted(mois):
        x = mois.pop(mk)
        es = x.pop("_e")
        x["ebe"] = round(_ebe(es), 2)
        x["litres"] = round(sum(e["q"] for e in es if e["c"] == "70200000" or (e["c"].startswith("7020") and e["u"] == "L" and e["q"])), 1)
        x["lait"] = round(-sum(e["s"] for e in es if e["c"].startswith("7020")), 2)
        mens.append(x)
    for i, x in enumerate(mens):
        x["glissant"] = round(sum(y["ebe"] for y in mens[i - 11:i + 1]), 2) if i >= 11 else None
    fins = []
    d = date(dmax.year, dmax.month, 1) - timedelta(days=1)
    while d >= dmin:
        fins.append(d.isoformat())
        d = date(d.year, d.month, 1) - timedelta(days=1)
    return {"source": "grand_livre", "generale": g, "analytiques": ana,
            "nom_generale": " · ".join(x["nom"] for x in gls),
            "mode": mode, "ref": ref.isoformat(), "min": dmin.isoformat(), "max": dmax.isoformat(),
            "debut": {"jour": debut[0], "mois": debut[1]}, "fins": [dmax.isoformat()] + fins,
            "exercices": clos, "non_clotures": non_clos, "mensuel": mens}


def analyser(fichiers, opt=None):
    generales, analytiques, gls, erreurs = [], [], [], []
    for f in fichiers:
        nom = f.get("nom", "")
        try:
            octets = base64.b64decode(f.get("b64", ""))
            lu = lire_fichier(nom, octets)
        except ErreurExport as e:
            erreurs.append(f"{nom} : {e}")
            continue
        except Exception:
            erreurs.append(f"{nom} : fichier illisible (ni classeur .xlsx ni CSV).")
            continue
        {"generale": generales, "analytique": analytiques, "grand_livre": gls}[lu["type"]].append(lu)
    if erreurs:
        raise ErreurExport(" ".join(erreurs))
    global _REMU_6413
    paires = ([(x["compte"], x["libelle"]) for g in generales for x in g["comptes"]]
              + [(x["compte"], x["libelle"].split(" - ")[0]) for a in analytiques for x in a["lignes"]]
              + [(e["c"], e["lib"]) for g in gls for e in g["ecritures"]])
    _REMU_6413 = any(c.startswith("6413") and _libelle_remu(l) for c, l in paires)
    if gls:
        if generales or analytiques:
            raise ErreurExport("déposez soit des grands livres, soit des balances, pas les deux à la fois.")
        return analyser_grands_livres(gls, opt or {})
    if not generales:
        raise ErreurExport("aucune balance générale ni aucun grand livre parmi les fichiers déposés "
                           "(les balances analytiques se reconnaissent à leur colonne « Activité »).")
    if len(generales) > 1:
        raise ErreurExport("plusieurs balances générales déposées : n'en gardez qu'une, la plus récente, "
                           "avec ses colonnes de comparaison.")
    g = calculer_generale(generales[0])
    ana = [calculer_analytique(a) for a in analytiques]
    ana.sort(key=lambda a: a["date"] or "", reverse=True)
    return {"source": "balance", "generale": g, "analytiques": ana, "nom_generale": generales[0]["nom"]}


def analyse_json(fichiers_json, options_json="{}"):
    """Point d'entrée de l'outil en ligne."""
    try:
        return json.dumps(analyser(json.loads(fichiers_json or "[]"), json.loads(options_json or "{}")))
    except ErreurExport as e:
        return json.dumps({"erreur": str(e)})
