from dataclasses import dataclass
import numpy as np
from scipy import stats
from scipy.stats import norm
import pandas as pd

@dataclass
class Zuverlaessigkeit:

    @staticmethod
    def gesamtredundanz(n, u):
        r = n - u
        return r


    @staticmethod
    def berechne_R(Q_vv, P):
        R = Q_vv @ P
        return R       #Redundanzmatrix


    @staticmethod
    def berechne_ri(R):
        ri = np.diag(R)
        EVi = 100.0 * ri
        return ri, EVi      #Redundanzanteile


    @staticmethod
    def klassifiziere_ri(ri):       #Klassifizierung der Redundanzanteile
        if ri < 0.01:
            return "nicht kontrollierbar"
        elif ri < 0.10:
            return "schlecht kontrollierbar"
        elif ri < 0.30:
            return "ausreichend kontrollierbar"
        elif ri < 0.70:
            return "gut kontrollierbar"
        else:
            return "nahezu vollständig redundant"

    @staticmethod
    def globaltest(r_gesamt, sigma0_apost, sigma0_apriori, alpha):
        T_G = (sigma0_apost ** 2) / (sigma0_apriori ** 2)
        F_krit = stats.f.ppf(1 - alpha, r_gesamt, 10 ** 9)
        H0 = T_G < F_krit

        if H0:
            interpretation = (
                "Nullhypothese H₀ angenommen.\n"
            )
        else:
            interpretation = (
                "Nullhypothese H₀ verworfen!\n"
                "Dies kann folgende Gründe haben:\n"
                "→ Es befinden sich grobe Fehler im Datenmaterial. Bitte Lokaltest durchführen und ggf. grobe Fehler im Datenmaterial entfernen.\n"
                "→ Das stochastische Modell ist zu optimistisch. Bitte Gewichte überprüfen und ggf. anpassen."
            )

        return {
            "r_gesamt": r_gesamt,
            "sigma0_apost": sigma0_apost,
            "sigma0_apriori": sigma0_apriori,
            "alpha": alpha,
            "T_G": T_G,
            "F_krit": F_krit,
            "H0_angenommen": H0,
            "Interpretation": interpretation,
        }


    def lokaltest_innere_Zuverlaessigkeit(v, Q_vv, ri, labels, s0_apost, alpha, beta):
        v = np.asarray(v, float).reshape(-1)
        Q_vv = np.asarray(Q_vv, float)
        ri = np.asarray(ri, float).reshape(-1)
        labels = list(labels)

        # Grobfehlerabschätzung:
        ri_ = np.where(ri == 0, np.nan, ri)
        GF = -v / ri_

        # Standardabweichungen der Residuen
        qv = np.diag(Q_vv).astype(float)
        s_vi = float(s0_apost) * np.sqrt(qv)

        # Normierte Verbesserung NV
        NV = np.abs(v) / s_vi

        # Quantile k und kA (zweiseitig),
        k = float(norm.ppf(1 - alpha / 2))
        kA = float(norm.ppf(1 - beta))    # (Testmacht 1-β)

        # Nichtzentralitätsparameter δ0
        nzp = k + kA

        # Grenzwert für die Aufdeckbarkeit eines GF (GRZW)
        GRZW_i = (s_vi / ri_) * nzp

        auffaellig = NV > nzp

        Lokaltest_innere_Zuv = pd.DataFrame({
            "Beobachtung": labels,
            "v_i": v,
            "r_i": ri,
            "s_vi": s_vi,
            "k": k,
            "NV_i": NV,
            "auffaellig": auffaellig,
            "GF_i": GF,
            "GRZW_i": GRZW_i,
            "alpha": alpha,
            "beta": beta,
            "kA": kA,
            "δ0": nzp,
        })
        return Lokaltest_innere_Zuv



    def aeussere_zuverlaessigkeit(
            Lokaltest, labels, Qxx, A, P, s0_apost, unbekannten_liste, x,
            angle_units="rad",
            ep_use_abs=True,
            exclude_prefixes=("lA_",),
    ):
        df = Lokaltest.copy()
        labels = [str(l) for l in list(labels)]

        Qxx = np.asarray(Qxx, float)
        A = np.asarray(A, float)
        P = np.asarray(P, float)
        x = np.asarray(x, float).reshape(-1)

        namen_str = [str(sym) for sym in unbekannten_liste]

        n = A.shape[0]
        if len(labels) != n:
            raise ValueError(f"len(labels)={len(labels)} passt nicht zu A.shape[0]={n}.")
        if len(df) != n:
            raise ValueError(f"Lokaltest hat {len(df)} Zeilen, A hat {n} Beobachtungen.")

        # Pseudobeobachtungen rausfiltern
        keep = np.ones(n, dtype=bool)
        if exclude_prefixes:
            for i, lbl in enumerate(labels):
                if any(lbl.startswith(pref) for pref in exclude_prefixes):
                    keep[i] = False

        # alles konsistent kürzen (wichtig: auch A & P!)
        df = df.loc[keep].reset_index(drop=True)
        labels = [lbl for (lbl, k) in zip(labels, keep) if k]
        A = A[keep, :]
        P = P[np.ix_(keep, keep)]

        # neue n
        n = A.shape[0]

        # Daten aus dem Lokaltest
        ri = df["r_i"].astype(float).to_numpy()
        GF = df["GF_i"].astype(float).to_numpy()
        GRZW = df["GRZW_i"].astype(float).to_numpy()

        s0 = float(s0_apost)

        def to_rad(val):
            if angle_units == "rad":
                return val
            if angle_units == "gon":
                return val * (np.pi / 200.0)
            if angle_units == "deg":
                return val * (np.pi / 180.0)
            raise ValueError("angle_units muss 'rad', 'gon' oder 'deg' sein.")

        # Punktkoordinaten aus x (für Streckenäquivalent bei Winkel-EP)
        coords = {}
        punkt_ids = sorted({name[1:] for name in namen_str
                            if name[:1].upper() in ("X", "Y", "Z") and len(name) > 1})
        for pid in punkt_ids:
            try:
                ix = namen_str.index(f"X{pid}")
                iy = namen_str.index(f"Y{pid}")
                iz = namen_str.index(f"Z{pid}")
                coords[pid] = (x[ix], x[iy], x[iz])
            except ValueError:
                continue

        # Standpunkt/Zielpunkt
        standpunkte = [""] * n
        zielpunkte = [""] * n
        for i, lbl in enumerate(labels):
            parts = lbl.split("_")
            sp, zp = None, None

            if any(k in lbl for k in ["_SD_", "_R_", "_ZW_"]):
                if len(parts) >= 5:
                    sp, zp = parts[3].strip(), parts[4].strip()
            elif "gnss" in lbl.lower():
                if len(parts) >= 2:
                    sp, zp = parts[-2].strip(), parts[-1].strip()
            elif "niv" in lbl.lower():
                if len(parts) >= 4:
                    sp = parts[3].strip()
                    if len(parts) >= 5:
                        zp = parts[4].strip()
                else:
                    sp = parts[-1].strip()

            standpunkte[i] = sp or ""
            zielpunkte[i] = zp or ""

        # Berechnung des EPs
        EP_GF = (1.0 - ri) * GF
        EP_grzw = (1.0 - ri) * GRZW
        if ep_use_abs:
            EP_GF = np.abs(EP_GF)
            EP_grzw = np.abs(EP_grzw)

        EP_hat_m = np.full(n, np.nan, float)
        EP_grzw_m = np.full(n, np.nan, float)

        for i, lbl in enumerate(labels):
            sp = standpunkte[i]
            zp = zielpunkte[i]

            is_angle = ("_R_" in lbl) or ("_ZW_" in lbl)
            if not is_angle:
                EP_hat_m[i] = EP_GF[i]
                EP_grzw_m[i] = EP_grzw[i]
                continue

            # Winkel -> Querabweichung = Winkel(rad) * Strecke (3D)
            if sp in coords and zp in coords:
                X1, Y1, Z1 = coords[sp]
                X2, Y2, Z2 = coords[zp]
                s = np.sqrt((X2 - X1) ** 2 + (Y2 - Y1) ** 2 + (Z2 - Z1) ** 2)

                EP_hat_m[i] = to_rad(EP_GF[i]) * s
                EP_grzw_m[i] = to_rad(EP_grzw[i]) * s

        # 3x3 Blöcke
        def idx_xyz(pid):
            return [
                namen_str.index(f"X{pid}"),
                namen_str.index(f"Y{pid}"),
                namen_str.index(f"Z{pid}")
            ]

        # EF lokal + SP lokal (3D)
        EF = np.full(n, np.nan, float)
        SP_loc_m = np.full(n, np.nan, float)
        EFSP_loc_m = np.full(n, np.nan, float)

        for i in range(n):
            sp = standpunkte[i]
            zp = zielpunkte[i]

            blocks = []
            idx = []

            try:
                if sp:
                    b = idx_xyz(sp)
                    blocks.append(b)
                    idx += b
                if zp:
                    b = idx_xyz(zp)
                    blocks.append(b)
                    idx += b
            except ValueError:
                continue

            if not blocks:
                continue

            idx = list(dict.fromkeys(idx))  # unique

            # Δx_i aus Grenzstörung
            dl = np.zeros((n, 1))
            dl[i, 0] = GRZW[i]
            dx = Qxx @ (A.T @ (P @ dl))

            dx_loc = dx[idx, :]
            Q_loc = Qxx[np.ix_(idx, idx)]

            # EF lokal
            EF2 = (dx_loc.T @ np.linalg.solve(Q_loc, dx_loc)).item() / (s0 ** 2)
            EF[i] = np.sqrt(max(0.0, EF2))

            # SP lokal 3D: max trace der 3x3 Punktblöcke
            tr_list = [np.trace(Qxx[np.ix_(b, b)]) for b in blocks]
            if not tr_list:
                continue

            sigmaPmax_loc = s0 * np.sqrt(max(tr_list))
            SP_loc_m[i] = sigmaPmax_loc
            EFSP_loc_m[i] = EF[i] * sigmaPmax_loc

        ausgabe_zuv = pd.DataFrame({
            "Beobachtung": labels,
            "Stand-Pkt": standpunkte,
            "Ziel-Pkt": zielpunkte,
            "r_i": ri,
            "EP_GF [mm]": EP_hat_m * 1000.0,
            "EP_grzw [mm]": EP_grzw_m * 1000.0,
            "EF": EF,
            "SP_loc_3D [mm]": SP_loc_m * 1000.0,
            "EF*SP_loc_3D [mm]": EFSP_loc_m * 1000.0,
        })
        return ausgabe_zuv
