from flask import (

    Flask, render_template, request,

    send_file, session, redirect, url_for

)

from openpyxl import load_workbook

from werkzeug.utils import secure_filename

from datetime import datetime

import os, re, json, uuid

app = Flask(__name__)

app.secret_key = "ved_qbr_secret_key_2025"

UPLOAD_FOLDER = "uploads"

OUTPUT_FOLDER = "output"

DATA_FOLDER = "session_data"

for d in [UPLOAD_FOLDER, OUTPUT_FOLDER, DATA_FOLDER]:

    os.makedirs(d, exist_ok=True)

def save_data(data):

    fid = str(uuid.uuid4())[:8]

    with open(os.path.join(DATA_FOLDER, f"{fid}.json"), "w", encoding="utf-8") as f:

        json.dump(data, f, ensure_ascii=False)

    return fid

def load_data(fid):

    if not fid:

        return None

    fp = os.path.join(DATA_FOLDER, f"{fid}.json")

    if not os.path.exists(fp):

        return None

    with open(fp, "r", encoding="utf-8") as f:

        return json.load(f)

def update_session_data(data):
    fid = session.get("did")

    if not fid:
        return False

    fp = os.path.join(DATA_FOLDER, f"{fid}.json")

    with open(fp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False)

    return True

# ============================================

# MAPPINGS

# ============================================

FAMILY_TO_CATEGORY = {

    "poweredge": "Compute", "dell networking": "Network",

    "connectrix": "Network", "force10": "Network",

    "oem networking": "Network",

    "dell storage oem": "Storage", "dell storage sc": "Storage",

    "dell compellent": "Storage", "powervault": "Storage",

    "powerstore": "Storage", "powermax": "Storage",

    "powerscale": "Storage", "unified": "Storage",

    "storage management": "Storage", "ecs": "Storage",

    "isilon": "Storage", "symmetrix": "Storage",

    "elastic cloud storage": "Storage",

    "storage resource mgmt": "Storage",

    "vxrail": "HCI", "recoverpoint": "Data Protection",

    "data domain": "Data Protection", "powerprotect": "Data Protection",

    "avamar": "Data Protection", "networker": "Data Protection",

    "appsync": "Data Protection",

    "multi platform": "Others",

}

NETWORK_OVERRIDE = [

    "VEP1405", "VEP1425", "VEP1445", "VEP1485", "VEP4600",

    "VEP1445N", "NETWORKING", "MX5108n", "MX9116n",

    "MXL Blade", "PowerSwitch", "SmartFabric", "OS10",

    "SONiC", "Force10",

]

CATEGORIES = ["Compute", "Storage", "Network", "HCI", "Data Protection", "Others"]

SUPPORT_TYPES = [

    "ProSupport Plus", "ProSupport Plus / PSONE",

    "ProSupport", "Basic Support / Warranty",

    "Multi Vendor Support",

    "Contract Expiring", "Contract Expired"

]

CATEGORIES = ["Compute", "Storage", "Network", "HCI", "Data Protection", "Others"]

# ============================================
# HEALTH RISK TYPES
# ============================================

HEALTH_RISK_TYPES = [
    "Securities & Technical Advisories",
    "Field Change Order",
    "PFN/PFR",
    "AIOPs Health",
]

# ============================================
# CAPACITY
# ============================================

CAPACITY_CATEGORIES = [
    "Storage",
    "Data Protection",
]

CAPACITY_TYPES = [
    "Array Usage > 80%",
]

SUPPORT_TYPES = [
    "ProSupport Plus", "ProSupport Plus / PSONE",
    "ProSupport", "Basic Support / Warranty",
    "Multi Vendor Support",
    "Contract Expiring", "Contract Expired"
]

CODE_LEVELS = ["Latest", "Recommended", "Minimum", "Below", "No Info"]

CODE_RANK = {"Latest": 4, "Recommended": 3, "Minimum": 2, "Below": 1, "No Info": 0}

KNOWN_FAMILIES = [

    "PowerEdge", "Dell Networking", "CONNECTRIX",

    "Dell Storage OEM", "Dell Storage SC", "Dell Compellent",

    "UNIFIED", "RECOVERPOINT", "MULTI PLATFORM", "VXRAIL",

    "POWERPROTECT", "DATA DOMAIN", "POWERVAULT", "POWERSTORE",

    "POWERMAX", "POWERSCALE", "ECS", "ISILON", "AVAMAR",

    "NETWORKER", "SYMMETRIX", "OEM Networking",

    "ELASTIC CLOUD STORAGE", "STORAGE RESOURCE MGMT",

]

PRODUCT_KEYWORDS = [

    "PowerEdge", "PowerVault", "VEP", "VxRail", "PowerStore",

    "PowerProtect", "NETWORKING", "Connectrix", "Unity",

    "Data Domain", "vSAN", "RecoverPoint", "DS-300", "DS-6",

    "ME5024", "ME5012", "ME4012", "MX840C", "MX740C", "MX7000",

    "Compellent", "SC220", "SC420", "Force10", "MXL Blade", "PowerMax",

    "ECS Appliance", "AppSync",

]

# ============================================

# VERSION COMPARISON

# ============================================

def normalize_version(v):

    if not v or v in ("None", "", "N/A"):
        return []

    v = str(v).strip()

    # Remove common prefixes
    for p in ["Dell ", "SBE ", "SN-", "v", "V"]:
        if v.startswith(p):
            v = v[len(p):]

    # Ignore the combined-version separator.
    # Example:
    # 7.30.30.51 _ 2.10.1
    # becomes two version parts.
    parts = re.split(r'\s*_\s*', v)

    result = []

    for part in parts:

        part = part.strip()

        if not part:
            continue

        # Split numeric and alphabetic pieces while preserving
        # the alphabetic suffix.
        #
        # Examples:
        # 9.2.2     -> ["9", "2", "2"]
        # 9.2.2c    -> ["9", "2", "2", "c"]
        # 9.2.0c3   -> ["9", "2", "0", "c", "3"]

        tokens = re.findall(r'\d+|[A-Za-z]+', part)

        parsed_part = []

        for token in tokens:

            if token.isdigit():
                parsed_part.append(("num", int(token)))
            else:
                parsed_part.append(("alpha", token.lower()))

        if parsed_part:
            result.append(parsed_part)

    return result

def compare_versions(v1, v2):

    n1 = normalize_version(v1)
    n2 = normalize_version(v2)

    if not n1 or not n2:
        return 0

    # Compare each combined microcode section separately.
    #
    # Example:
    #
    # 7.30.30.51 _ 2.10.1
    #
    # is compared as:
    #
    # 7.30.30.51
    #       then
    # 2.10.1

    max_parts = max(len(n1), len(n2))

    for i in range(max_parts):

        if i >= len(n1):
            return -1

        if i >= len(n2):
            return 1

        p1 = n1[i]
        p2 = n2[i]

        max_tokens = max(len(p1), len(p2))

        for j in range(max_tokens):

            if j >= len(p1):
                # Missing token means no suffix/value.
                #
                # Example:
                # 9.2.2
                # vs
                # 9.2.2c
                #
                # Bare version is lower than the
                # version with an alphabetic suffix.
                return -1

            if j >= len(p2):
                return 1

            t1, val1 = p1[j]
            t2, val2 = p2[j]

            # Same token type
            if t1 == t2:

                if val1 > val2:
                    return 1

                if val1 < val2:
                    return -1

            # Numeric version component comes before
            # an alphabetic suffix.
            elif t1 == "num" and t2 == "alpha":

                return -1

            elif t1 == "alpha" and t2 == "num":

                return 1

    return 0

def classify_single(mc_val, min_val, rec_val, latest_val):

    if not mc_val or mc_val in ("None", "", "N/A"):
        return "No Info"

    gl = str(latest_val).strip() if latest_val and latest_val not in ("None", "") else ""
    gr = str(rec_val).strip() if rec_val and rec_val not in ("None", "") else ""
    gm = str(min_val).strip() if min_val and min_val not in ("None", "") else ""

    if not gl and not gr and not gm:
        return "No Info"

    mc = str(mc_val).strip()

    # 1. Latest
    # Current >= Latest
    if gl and compare_versions(mc, gl) >= 0:
        return "Latest"

    # 2. Recommended
    # Current is below Latest but >= Recommended
    if gr and compare_versions(mc, gr) >= 0:
        return "Recommended"

    # 3. Below
    # Current < Recommended
    if gr and compare_versions(mc, gr) < 0:
        return "Below"

    # 4. Existing Minimum logic
    if gm and compare_versions(mc, gm) >= 0:
        return "Minimum"

    # 5. No Info
    if gm or gr or gl:
        return "Below"

    return "No Info"

def classify_code(mc1, mc2, gsmc_min, gsmc_rec, gsmc_latest, sw_ver):

    """
    Classify code health using the customer's Software Health status
    as the authoritative classification when it is explicitly provided.

    Customer Software Health uses these statuses:
        Up to Date (Latest)
        Up to Date (Recommended)
        Update Soon (Minimum)
        Out-of-date (Below)
        Unable to determine

    When one of these explicit statuses is present, do not infer a
    different bucket from version arithmetic.  This prevents assets
    marked "Update Soon (Minimum)" from being incorrectly moved into
    "Below" simply because their current version is below the
    Recommended Version.

    If the source status is unavailable/unrecognized, fall back to the
    existing version-comparison logic.
    """

    sv = str(sw_ver or "").strip().upper()

    # ------------------------------------------------
    # SOFTWARE HEALTH SOURCE-OF-TRUTH STATUS
    # ------------------------------------------------
    # The uploaded customer workbook explicitly supplies the code-health
    # bucket in "Software Version".  Use it first.
    if "UNABLE TO DETERMINE" in sv:
        return "No Info"

    if "UP TO DATE (LATEST)" in sv:
        return "Latest"

    if "UP TO DATE (RECOMMENDED)" in sv:
        return "Recommended"

    if "UPDATE SOON (MINIMUM)" in sv:
        return "Minimum"

    if "OUT-OF-DATE (BELOW)" in sv or "OUT OF DATE (BELOW)" in sv:
        return "Below"

    # ------------------------------------------------
    # FALLBACK: existing version comparison logic
    # ------------------------------------------------
    mc1_val = str(mc1).strip() if mc1 and mc1 not in ("None", "", "N/A") else ""
    mc2_val = str(mc2).strip() if mc2 and mc2 not in ("None", "", "N/A") else ""

    if not mc1_val and not mc2_val:
        return "No Info"

    if mc1_val and mc2_val:
        current_version = f"{mc1_val} _ {mc2_val}"
    elif mc1_val:
        current_version = mc1_val
    else:
        current_version = mc2_val

    latest_version = (
        str(gsmc_latest).strip()
        if gsmc_latest and gsmc_latest not in ("None", "", "N/A")
        else ""
    )

    recommended_version = (
        str(gsmc_rec).strip()
        if gsmc_rec and gsmc_rec not in ("None", "", "N/A")
        else ""
    )

    minimum_version = (
        str(gsmc_min).strip()
        if gsmc_min and gsmc_min not in ("None", "", "N/A")
        else ""
    )

    if not latest_version and not recommended_version and not minimum_version:
        return "No Info"

    if latest_version:
        if compare_versions(current_version, latest_version) >= 0:
            return "Latest"

    if recommended_version:
        if compare_versions(current_version, recommended_version) >= 0:
            return "Recommended"

    if minimum_version:
        if compare_versions(current_version, minimum_version) >= 0:
            return "Minimum"

    if recommended_version:
        return "Below"

    if minimum_version or latest_version:
        return "Below"

    return "No Info"

# ============================================

# HELPERS

# ============================================

def get_category(pf, pn=""):

    if pn:

        nu = str(pn).upper()

        for kw in NETWORK_OVERRIDE:

            if kw.upper() in nu:

                return "Network"

    if not pf:

        return "Others"

    fl = str(pf).strip().lower()

    for key, cat in FAMILY_TO_CATEGORY.items():

        if key in fl:

            return cat

    return "Others"

def classify_support_from_desc(desc):

    """Classify support from 'Support Max Contract Desc' column [2][5]"""

    if not desc:

        return "Basic Support / Warranty"

    s = str(desc).upper()

    # MVS must take precedence over every other support keyword.
    # This prevents a value containing both ProSupport and Multi Vendor
    # from being incorrectly classified as ProSupport.
    if "MULTI VENDOR" in s or "MULTIVENDOR" in s or "MULTI-VENDOR" in s:

        return "Multi Vendor Support"

    if "PROSUPPORT ONE" in s:

        return "ProSupport Plus / PSONE"

    elif "PROSUPPORT PLUS" in s:

        return "ProSupport Plus"

    elif "PROSUPPORT" in s:

        return "ProSupport"

    elif "BASIC" in s:

        return "Basic Support / Warranty"

    return "Basic Support / Warranty"

def determine_contract_status(last_contract_end, today):

    """From 'Last Contract End Date' column [2][5]

    Returns: 'active', 'expiring', 'expired', 'old_expired', None"""

    if not last_contract_end:

        return None

    try:

        if isinstance(last_contract_end, datetime):

            end_date = last_contract_end

        else:

            ds = str(last_contract_end).split(" ")[0].strip()

            if not ds or ds in ("None", "", "N/A"):

                return None

            end_date = datetime.strptime(ds, "%Y-%m-%d")

    except Exception:

        return None

    diff = (end_date - today).days

    months_ago = (today - end_date).days / 30.0

    if diff > 90:

        return "active"

    elif diff > 0:

        return "expiring"

    elif months_ago <= 13:

        return "expired"

    else:

        return "old_expired"

def is_psp(st):

    return st in ["ProSupport Plus", "ProSupport Plus / PSONE"]

def is_psp_from_desc(desc):
    """PSP = ProSupport Plus + ProSupport One.
    Contract status does not affect PSP classification.
    """

    if not desc:
        return False

    s = str(desc).strip().upper()

    return (
        "PROSUPPORT PLUS" in s
        or "PROSUPPORT ONE" in s
    )

def extract_customer(filename):

    name = os.path.splitext(filename)[0]

    for rm in ["_Updated", "_updated", "GSMC", "Enterprise Products", "- Enterprise",

               "August", "September", "October", "November", "December", "January",

               "February", "March", "April", "May", "June", "July",

               "2024", "2025", "2026", "2027", "_", "-"]:

        name = name.replace(rm, " ")

    name = re.sub(r'\d{4,}', '', name)

    return re.sub(r'\s+', ' ', name).strip(" -_.") or "Customer"

def empty_matrix(keys, cats):

    return {k: {c: 0 for c in cats} for k in keys}

def find_type_col(ws):

    """Find Product Type column by scanning DATA VALUES

    for 'HARDWARE'/'SOFTWARE' — not header names"""

    for row in ws.iter_rows(min_row=2, max_row=10, values_only=True):

        if not row:

            continue

        for ci, cv in enumerate(row):

            if str(cv or "").strip().upper() in ("HARDWARE", "SOFTWARE"):

                return ci

    return None

def find_family_col(ws, type_col):

    if type_col is not None:

        for row in ws.iter_rows(min_row=2, max_row=10, values_only=True):

            if not row:

                continue

            nc = type_col + 1

            if nc < len(row) and str(row[nc] or "").strip() in KNOWN_FAMILIES:

                return nc

    for row in ws.iter_rows(min_row=2, max_row=10, values_only=True):

        if not row:

            continue

        for ci, cv in enumerate(row):

            if str(cv or "").strip() in KNOWN_FAMILIES:

                return ci

    return None

def find_product_col(ws):

    for row in ws.iter_rows(min_row=2, max_row=10, values_only=True):

        if not row:

            continue

        for ci, cv in enumerate(row):

            if any(k in str(cv or "") for k in PRODUCT_KEYWORDS):

                return ci

    return None

def find_col_hdr(headers, names):

    for idx, h in enumerate(headers):

        if h and str(h).strip().lower() in [n.lower() for n in names]:

            return idx

    for idx, h in enumerate(headers):

        if h:

            hl = str(h).strip().lower()

            for n in names:

                if n.lower() in hl:

                    return idx

    return None

def find_install_col(ws, headers):

    idx = find_col_hdr(headers, ["install base status"])

    if idx is not None:

        return idx

    for row in ws.iter_rows(min_row=2, max_row=10, values_only=True):

        if not row:

            continue

        for ci, cv in enumerate(row):

            if str(cv or "").strip() == "Install":

                return ci

    return None

def find_sw_version_col(ws, headers):

    idx = find_col_hdr(headers, ["software version"])

    if idx is not None:

        return idx

    for row in ws.iter_rows(min_row=2, max_row=10, values_only=True):

        if not row:

            continue

        for ci, cv in enumerate(row):

            cs = str(cv or "").strip()

            if cs in ("Unable to determine", "Up to Date",

                      "Up to Date (Latest)", "Update Soon (Minimum)"):

                return ci

    return None

def scan_connectivity(row):

    for cv in row:

        cs = str(cv or "")

        if cs == "Connected":

            return "Connected"

        elif "Eligible - Not Active" in cs:

            return "Eligible - Not Active"

        elif cs == "Not Eligible":

            return "Not Eligible"

    return ""

def find_primary_sheet(sheet_names):

    """

    ✅ Find the primary sheet for entitlement/code calculations.

    Priority:

      1. Exact match 'all assets'

      2. Exact match 'assets'

      3. Contains 'all assets'

    Sheets like 'Expired Assets' or 'Asset Refresh' will NOT

    match because they are not exact matches to 'assets' [1].

    """

    for sn in sheet_names:

        if sn.strip().lower() == "all assets":

            return sn

    for sn in sheet_names:

        if sn.strip().lower() == "assets":

            return sn

    for sn in sheet_names:

        if "all assets" in sn.strip().lower():

            return sn

    return None

# ============================================

# ROUTES

# ============================================

@app.route("/")

def home():

    return render_template("index.html")

def _collect_uploaded_files():
    """Collect uploaded Excel files regardless of the form field name."""
    files = []
    seen = set()
    for key in request.files.keys():
        for f in request.files.getlist(key):
            if f and getattr(f, "filename", "") and id(f) not in seen:
                files.append(f)
                seen.add(id(f))
    return files


@app.route("/process", methods=["GET", "POST"])
@app.route("/upload", methods=["GET", "POST"])
def process():
    # Opening/refeshing /process directly must never show "No file selected".
    if request.method == "GET":
        return redirect(url_for("home"))

    # Accept the normal field plus legacy/alternate upload field names.
    files = _collect_uploaded_files()
    if not files:
        for key in ("file", "files", "files[]", "excel_file", "upload_file", "uploaded_file"):
            f = request.files.get(key)
            if f and getattr(f, "filename", ""):
                files = [f]
                break

    if not files:
        return (
            "No file was received by the server. Please select the Excel file "
            "in the upload box and click Process & Generate Report.", 400
        )

    # ------------------------------------------------------------
    # CLIENT / ENTERPRISE ROUTING ONLY
    # ------------------------------------------------------------
    # Read the report type selected in index.html. This is intentionally
    # kept separate from the existing workbook/metric calculations below.
    # Existing processing logic remains unchanged.
    report_types = request.form.getlist("file_types")
    if not report_types:
        report_types = request.form.getlist("file_type")
    if not report_types:
        report_types = request.form.getlist("report_type")

    report_type = (
        report_types[0].strip().lower()
        if report_types and report_types[0]
        else "standard"
    )
    if report_type not in ("standard", "client"):
        report_type = "standard"

    file = files[0]

    if not file.filename.lower().endswith((".xlsx", ".xls")):

        return "Upload .xlsx or .xls files only", 400

    filename = secure_filename(file.filename)

    filepath = os.path.join(UPLOAD_FOLDER, filename)

    file.save(filepath)

    customer = extract_customer(file.filename)

    try:

        wb = load_workbook(filepath, data_only=True)

    except Exception as e:

        return f"Error: {e}", 500

    today = datetime.now()

    # ============================================================
    # SOFTWARE HEALTH TOTAL
    # Source of truth for Code Health percentage denominator.
    # This is the number of data rows in the actual Software Health
    # sheet, not the total asset population from Assets/All Assets.
    # ============================================================
    software_health_total = 0
    for _sn in wb.sheetnames:
        if _sn.strip().lower() == "software health":
            _ws_health = wb[_sn]
            software_health_total = sum(
                1
                for _row in _ws_health.iter_rows(min_row=2, values_only=True)
                if any(
                    _cell is not None and str(_cell).strip() != ""
                    for _cell in _row
                )
            )
            break

    print(f"[SHEET COUNTS] Software Health data rows: {software_health_total}")

    total_hw = 0

    cc = {c: 0 for c in CATEGORIES}

    sm_all = empty_matrix(SUPPORT_TYPES, CATEGORIES)

    cm_all = empty_matrix(CODE_LEVELS, CATEGORIES)

    psp_n = 0

    npsp_n = 0

    psp_cc = {c: 0 for c in CATEGORIES}

    npsp_cc = {c: 0 for c in CATEGORIES}

    psp_sm = empty_matrix(SUPPORT_TYPES, CATEGORIES)

    npsp_sm = empty_matrix(SUPPORT_TYPES, CATEGORIES)

    psp_cm = empty_matrix(CODE_LEVELS, CATEGORIES)

    npsp_cm = empty_matrix(CODE_LEVELS, CATEGORIES)

    ins_n = 0

    nins_n = 0

    ins_cc = {c: 0 for c in CATEGORIES}

    nins_cc = {c: 0 for c in CATEGORIES}

    ins_sm = empty_matrix(SUPPORT_TYPES, CATEGORIES)

    nins_sm = empty_matrix(SUPPORT_TYPES, CATEGORIES)

    ins_cm = empty_matrix(CODE_LEVELS, CATEGORIES)

    nins_cm = empty_matrix(CODE_LEVELS, CATEGORIES)

    hw_count = 0

    sw_count = 0

    conn = {"connected": 0, "eligible": 0, "not_eligible": 0}

    assets = []

    sheets = []

    prod_cnt = {}

    seen = set()

    dup_count = 0

    asset_index = {}

    active_n = 0

    tm_n = 0

    # ============================================
    # HEALTH RISKS
    # ============================================

    HEALTH_RISK_TYPES = [
        "Securities & Technical Advisories",
        "Field Change Order",
        "PFN/PFR",
        "AIOPs Health",
    ]

    # ============================================
    # CAPACITY
    # ============================================

    CAPACITY_CATEGORIES = ["Storage", "Data Protection"]

    CAPACITY_TYPES = [
        "Array Usage > 80%"
    ]

    health_risks = {
        category: {
            risk: 0
            for risk in HEALTH_RISK_TYPES
        }
        for category in CATEGORIES
    }

    # ============================================
    # CAPACITY
    # ============================================

    capacity = {
        category: {
            "Array Usage > 80%": 0
        }
        for category in CAPACITY_CATEGORIES
    }
    

    print(f"\n{'='*60}")

    print(f"[INFO] File: {filename}")

    print(f"[INFO] Customer: {customer}")

    print(f"[INFO] Sheets: {wb.sheetnames}")

    print(f"{'='*60}")

    # ============================================

    # ✅ FIND PRIMARY SHEET: 'All Assets' or 'Assets' [1][2][5]

    # ============================================

    primary_sheet = find_primary_sheet(wb.sheetnames)

    other_sheets = [sn for sn in wb.sheetnames if sn != primary_sheet]

    # ============================================
    # ACTIVE / EXPIRED DASHBOARD COUNTS
    # Source: actual data rows only
    # Header row is NOT counted
    #
    # Active  -> Assets
    # Expired -> Expired Assets
    # ============================================

    if "Assets" in wb.sheetnames:
        ws_assets_count = wb["Assets"]

        active_n = sum(
            1
            for row in ws_assets_count.iter_rows(
                min_row=2,
                values_only=True
            )
            if any(
                cell is not None and str(cell).strip() != ""
                for cell in row
            )
        )
    else:
        active_n = 0


    if "Expired Assets" in wb.sheetnames:
        ws_expired_count = wb["Expired Assets"]

        tm_n = sum(
            1
            for row in ws_expired_count.iter_rows(
                min_row=2,
                values_only=True
            )
            if any(
                cell is not None and str(cell).strip() != ""
                for cell in row
            )
        )
    else:
        tm_n = 0


    print(f"[SHEET COUNTS] Assets data rows: {active_n}")
    print(f"[SHEET COUNTS] Expired Assets data rows: {tm_n}")

    print(f"[SHEET COUNTS] Assets: {active_n}")
    print(f"[SHEET COUNTS] Expired Assets: {tm_n}")

    # ============================================
    # EXACT HARDWARE / SOFTWARE COUNT
    # SOURCE: Assets sheet ONLY
    # Matches Excel Product Type filter.
    # ============================================

    hw_count = 0
    sw_count = 0
    assets_total = 0

    assets_ws = wb["Assets"]
    assets_headers = [cell.value for cell in assets_ws[1]]

    # ============================================
    # CONNECTION HISTORY
    # Source:
    #   Assets -> Connection Status
    #   Connectivity -> Column D
    #       Device Last Connect Date
    # ============================================

    conn_history = {
        "0-7": 0,
        "8-32": 0,
        "33-179": 0,
        "180+": 0,
        "No Hist": 0
    }

    # Find Connection Status column in Assets
    connection_status_col = find_col_hdr(
        assets_headers,
        ["connection status"]
    )

    if connection_status_col is None:
        raise ValueError(
            "Connection Status column not found in Assets sheet."
        )

    # --------------------------------------------
    # Find the device identifier column
    # --------------------------------------------

    assets_id_col = find_col_hdr(
        assets_headers,
        ["asset id", "service tag", "svctag"]
    )

    if "Connectivity" in wb.sheetnames and assets_id_col is not None:

        connectivity_ws = wb["Connectivity"]

        connectivity_headers = [
            cell.value for cell in connectivity_ws[1]
        ]

        connectivity_id_col = find_col_hdr(
            connectivity_headers,
            ["asset id", "service tag", "svctag", "device id"]
        )

        # Column D = Device Last Connect Date
        last_connect_col = 3

        # ----------------------------------------
        # Collect Eligible - Not Active assets
        # ----------------------------------------

        eligible_asset_ids = set()

        for row in assets_ws.iter_rows(
            min_row=2,
            values_only=True
        ):

            if not any(row):
                continue

            status = str(
                row[connection_status_col] or ""
            ).strip().upper()

            if status == "ELIGIBLE - NOT ACTIVE":

                asset_id = str(
                    row[assets_id_col] or ""
                ).strip().upper()

                if asset_id:
                    eligible_asset_ids.add(asset_id)

        # ----------------------------------------
        # Build Connectivity lookup
        # ----------------------------------------

        connectivity_history = {}

        for row in connectivity_ws.iter_rows(
            min_row=2,
            values_only=True
        ):

            if not row:
                continue

            if connectivity_id_col is not None:

                device_id = str(
                    row[connectivity_id_col] or ""
                ).strip().upper()

            else:

                device_id = ""

            last_connect_date = (
                row[last_connect_col]
                if last_connect_col < len(row)
                else None
            )

            if device_id:
                connectivity_history[
                    device_id
                ] = last_connect_date

        # ----------------------------------------
        # Classify eligible devices
        # ----------------------------------------

        for asset_id in eligible_asset_ids:

            last_connect_date = connectivity_history.get(
                asset_id
            )

            if not last_connect_date:

                conn_history["No Hist"] += 1
                continue

            try:

                if isinstance(last_connect_date, datetime):

                    connect_date = last_connect_date

                else:

                    date_text = str(
                        last_connect_date
                    ).strip()

                    if not date_text:
                        raise ValueError

                    connect_date = None

                    for fmt in (
                        "%Y-%m-%d",
                        "%Y-%m-%d %H:%M:%S",
                        "%m/%d/%Y",
                        "%m/%d/%Y %H:%M:%S",
                        "%d-%m-%Y",
                        "%d/%m/%Y",
                    ):

                        try:

                            connect_date = datetime.strptime(
                                date_text,
                                fmt
                            )

                            break

                        except ValueError:

                            continue

                    if connect_date is None:
                        raise ValueError

                days_since_connect = (
                    today.date() - connect_date.date()
                ).days

                if days_since_connect < 0:
                    days_since_connect = 0

                if days_since_connect <= 7:

                    conn_history["0-7"] += 1

                elif days_since_connect <= 32:

                    conn_history["8-32"] += 1

                elif days_since_connect <= 179:

                    conn_history["33-179"] += 1

                else:

                    conn_history["180+"] += 1

            except Exception:

                conn_history["No Hist"] += 1


    print(
        f"[CONNECTION HISTORY] "
        f"0-7:{conn_history['0-7']} "
        f"8-32:{conn_history['8-32']} "
        f"33-179:{conn_history['33-179']} "
        f"180+:{conn_history['180+']} "
        f"No Hist:{conn_history['No Hist']}"
    )

    # ============================================
    # CONNECTIVITY COUNTS
    # SOURCE: Assets sheet ONLY
    # COLUMN: Connection Status
    #
    # Connected Devices:
    #     Connection Status = Connected
    #
    # Eligible:
    #     Connection Status = Eligible - Not Active
    #
    # All other statuses are ignored.
    # ============================================

    conn = {
        "connected": 0,
        "eligible": 0,
        "not_eligible": 0
    }

    connection_status_col = find_col_hdr(
        assets_headers,
        ["connection status"]
    )

    if connection_status_col is None:
        raise ValueError(
            "Connection Status column not found in Assets sheet."
        )

    for row in assets_ws.iter_rows(
        min_row=2,
        values_only=True
    ):

        if not any(row):
            continue

        connection_status = str(
            row[connection_status_col] or ""
        ).strip().upper()

        if connection_status == "CONNECTED":

            conn["connected"] += 1

        elif connection_status == "ELIGIBLE - NOT ACTIVE":

            conn["eligible"] += 1

    product_type_col = find_col_hdr(
        assets_headers,
        ["product type"]
    )

    if product_type_col is None:
        raise ValueError("Product Type column not found in Assets sheet.")

    for row in assets_ws.iter_rows(min_row=2, values_only=True):

        if not any(row):
            continue

        assets_total += 1

        product_type = str(
            row[product_type_col] or ""
        ).strip().upper()

        if product_type == "HARDWARE":
            hw_count += 1

        elif product_type == "SOFTWARE":
            sw_count += 1

    print(
        f"[ASSETS PRODUCT TYPE] "
        f"Total:{assets_total} "
        f"Hardware:{hw_count} "
        f"Software:{sw_count}"
    )

    if primary_sheet:

        sheet_order = [primary_sheet] + other_sheets

        print(f"[INFO] Primary sheet: '{primary_sheet}'")

    else:

        sheet_order = wb.sheetnames

        print("[WARN] No 'All Assets'/'Assets' sheet found — "

              "all sheets will feed entitlement/code tables")

    for sn in sheet_order:

        ws = wb[sn]

        hdrs = [cell.value for cell in ws[1]]

        if not hdrs or all(h is None for h in hdrs):

            continue

        is_primary = (sn == primary_sheet) or (primary_sheet is None)

        print(f"\n[SHEET] '{sn}' {'<- PRIMARY' if is_primary else '(secondary)'}")

        type_col = find_type_col(ws)

        family_col = find_family_col(ws, type_col)

        product_col = find_product_col(ws)

        asset_col = find_col_hdr(hdrs, ["asset id", "service tag", "svctag"])

        micro1_col = find_col_hdr(hdrs, ["microcode 1", "microcode"])

        micro2_col = find_col_hdr(hdrs, ["microcode 2"])

        rec_col = find_col_hdr(hdrs, ["recommended version", "recommended"])

        latest_col = find_col_hdr(hdrs, ["latest version", "latest"])

        min_col = find_col_hdr(hdrs, ["minimum version", "minimum"])

        location_col = find_col_hdr(
            hdrs,
            ["location name", "location"]
        )

        location_id_col = find_col_hdr(
            hdrs,
            ["location id"]
        )

        address_col = find_col_hdr(
            hdrs,
            ["address", "location address"]
        )

        ship_date_col = find_col_hdr(
            hdrs,
            ["ship date"]
        )

        install_col = find_install_col(ws, hdrs)

        sw_ver_col = find_sw_version_col(ws, hdrs)

        support_desc_col = find_col_hdr(hdrs, ["support active contract desc"])
        support_max_desc_col = find_col_hdr(hdrs, ["support max contract desc"])

        contract_end_col = find_col_hdr(hdrs, ["last contract end date"])

        has_microcode = micro1_col is not None

        has_support_desc = support_desc_col is not None

        print(f"[COLS] type:{type_col} family:{family_col} product:{product_col} "

              f"asset:{asset_col} mc1:{micro1_col} mc2:{micro2_col} "

              f"support_desc:{support_desc_col} contract_end:{contract_end_col}")

        if type_col is None:

            sheets.append({"name": sn, "hardware": 0, "software": 0,

                           "skipped": 0, "duplicates": 0, "primary": is_primary})

            continue

        cat_ci = len(hdrs) + 1

        ws.cell(row=1, column=cat_ci, value="Category")

        # ============================================
        # SET COMPLETE AUTOFILTER RANGE
        # Allows Excel to filter all columns together,
        # including Support Active Contract Desc (M)
        # and generated Category (AX).
        # ============================================

        ws.auto_filter.ref = (
            f"A1:{ws.cell(row=1, column=cat_ci).column_letter}"
            f"{ws.max_row}"
        )

        s_hw = 0

        s_sw = 0

        s_skip = 0

        s_dup = 0

        upd_success = 0

        upd_support_success = 0

        for rn, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):

            if not any(row):

                continue

            def val(ci):

                if ci is not None and ci < len(row):

                    v = row[ci]

                    return str(v) if v is not None else ""

                return ""

            product_type = val(type_col).strip().upper()

            if product_type not in ("HARDWARE", "SOFTWARE"):

                s_skip += 1

                ws.cell(row=rn, column=cat_ci, value="SKIPPED")

                continue

            is_hw = product_type == "HARDWARE"

            aid = val(asset_col)

            if not aid or aid in ("None", "", "False", "True"):

                for cv in row:

                    cs = str(cv or "").strip()

                    if (5 <= len(cs) <= 20 and cs.isalnum() and not cs.isdigit()

                            and cs.upper() not in ("FALSE", "TRUE", "HARDWARE", "SOFTWARE")

                            and cs not in KNOWN_FAMILIES):

                        aid = cs

                        break

            # ============================================

            # SMART DEDUPLICATION — update microcode/support

            # on existing entries regardless of sheet primacy

            # ============================================

            if aid and aid not in ("None", ""):

                if aid in seen:

                    if aid in asset_index:

                        idx = asset_index[aid]

                        if idx < len(assets) and assets[idx].get("counted"):

                            # --------------------------------------------------------
                            # REFERENCE / CODE HEALTH UPDATE
                            #
                            # Software Health can provide Recommended/Latest/Minimum
                            # even when its Microcode 1/2 cells are blank.  In that
                            # case use the existing asset's current microcode and
                            # refresh the classification from the new references.
                            # --------------------------------------------------------
                            if has_microcode:
                                mc1_new = val(micro1_col)
                                mc2_new = val(micro2_col)
                            else:
                                mc1_new = ""
                                mc2_new = ""

                            rv_new = val(rec_col) if rec_col is not None else ""
                            lv_new = val(latest_col) if latest_col is not None else ""
                            mv_new = val(min_col) if min_col is not None else ""
                            sv_new = val(sw_ver_col) if sw_ver_col is not None else ""

                            has_reference = any(
                                str(v or "").strip() not in ("", "None", "N/A")
                                for v in (rv_new, lv_new, mv_new)
                            )

                            if has_reference:
                                current_mc1 = mc1_new or assets[idx].get("m", "")
                                current_mc2 = mc2_new or assets[idx].get("m2", "")
                                new_level = classify_code(
                                    current_mc1, current_mc2,
                                    mv_new, rv_new, lv_new,
                                    sv_new
                                )

                                old_level = assets[idx]["cl"]
                                old_cat = assets[idx]["c"]
                                old_psp = assets[idx]["psp"]
                                old_ins = assets[idx]["ins"]

                                if new_level != old_level:
                                    cm_all[old_level][old_cat] -= 1
                                    cm_all[new_level][old_cat] += 1
                                    if old_psp:
                                        psp_cm[old_level][old_cat] -= 1
                                        psp_cm[new_level][old_cat] += 1
                                    else:
                                        npsp_cm[old_level][old_cat] -= 1
                                        npsp_cm[new_level][old_cat] += 1
                                    if old_ins:
                                        ins_cm[old_level][old_cat] -= 1
                                        ins_cm[new_level][old_cat] += 1
                                    else:
                                        nins_cm[old_level][old_cat] -= 1
                                        nins_cm[new_level][old_cat] += 1
                                    assets[idx]["cl"] = new_level
                                    upd_success += 1

                                if mc1_new:
                                    assets[idx]["m"] = str(mc1_new)[:25]
                                if mc2_new:
                                    assets[idx]["m2"] = str(mc2_new)[:15]
                                if rv_new and str(rv_new).strip() not in ("None", "N/A"):
                                    assets[idx]["r"] = str(rv_new)[:30]

                            # --------------------------------------------------------
                            # SUPPORT UPDATE
                            # MVS is allowed to override an earlier generic support
                            # classification on the same asset.  This is important
                            # when the authoritative MVS row appears in a later
                            # sheet/duplicate record.
                            # --------------------------------------------------------
                            sd_new = val(support_desc_col) if support_desc_col is not None else ""
                            if (not sd_new or sd_new in ("None", "")) and support_max_desc_col is not None:
                                sd_new = val(support_max_desc_col)

                            if sd_new and sd_new not in ("None", ""):
                                new_sup = classify_support_from_desc(sd_new)
                                should_update_support = (
                                    new_sup == "Multi Vendor Support"
                                    or not assets[idx].get("has_support")
                                )

                                if should_update_support:
                                    ce_new = val(contract_end_col) if contract_end_col is not None else ""
                                    old_sup = assets[idx]["st"]
                                    old_cat = assets[idx]["c"]
                                    old_psp = assets[idx]["psp"]
                                    old_ins = assets[idx]["ins"]

                                    if new_sup != "Multi Vendor Support":
                                        cstatus = determine_contract_status(ce_new, today)
                                        if cstatus == "expiring":
                                            new_sup = "Contract Expiring"
                                        elif cstatus == "expired":
                                            new_sup = "Contract Expired"

                                    if new_sup != old_sup:
                                        sm_all[old_sup][old_cat] -= 1
                                        sm_all[new_sup][old_cat] += 1
                                        if old_psp:
                                            psp_sm[old_sup][old_cat] -= 1
                                            psp_sm[new_sup][old_cat] += 1
                                        else:
                                            npsp_sm[old_sup][old_cat] -= 1
                                            npsp_sm[new_sup][old_cat] += 1
                                        if old_ins:
                                            ins_sm[old_sup][old_cat] -= 1
                                            ins_sm[new_sup][old_cat] += 1
                                        else:
                                            nins_sm[old_sup][old_cat] -= 1
                                            nins_sm[new_sup][old_cat] += 1

                                    # PSP classification MUST use Support Max Contract Desc.
                                    # Support Active Contract Desc is not used for PSP counting.
                                    psp_desc_new = (
                                        val(support_max_desc_col)
                                        if support_max_desc_col is not None else ""
                                    )
                                    new_psp = is_psp_from_desc(psp_desc_new)
                                    if old_psp != new_psp:
                                        if old_psp:
                                            psp_n -= 1; npsp_n += 1
                                        else:
                                            npsp_n -= 1; psp_n += 1

                                    assets[idx]["st"] = new_sup
                                    assets[idx]["psp"] = new_psp
                                    assets[idx]["has_support"] = True
                                    if ce_new:
                                        assets[idx]["ce"] = str(ce_new)[:10]
                                    upd_support_success += 1

                    s_dup += 1

                    dup_count += 1

                    ws.cell(row=rn, column=cat_ci, value="DUPLICATE")

                    continue

                seen.add(aid)

            # ---- Extract values for new asset ----

            pf = val(family_col)

            pn = val(product_col)

            mc1 = val(micro1_col)

            mc2 = val(micro2_col)

            rv = val(rec_col)

            lv = val(latest_col)

            mv = val(min_col)

            loc = val(location_col)

            sw_ver = val(sw_ver_col)

            support_desc = val(support_desc_col)
            if (not support_desc or support_desc in ("None", "")) and support_max_desc_col is not None:
                support_desc = val(support_max_desc_col)

            contract_end = val(contract_end_col)

            if not pf or pf in ("None", "", "False", "True", "Enterprise Products"):

                for cv in row:

                    if str(cv or "").strip() in KNOWN_FAMILIES:

                        pf = str(cv or "").strip()

                        break

            if not pn or pn in ("None", "", "False", "True"):

                for cv in row:

                    cs = str(cv or "")

                    if any(k in cs for k in PRODUCT_KEYWORDS):

                        pn = cs

                        break

            ist = val(install_col)

            if not ist or ist in ("None", ""):

                for cv in row:

                    if str(cv or "").strip() == "Install":

                        ist = "Install"

                        break

            is_inst = ist.strip() == "Install"

            # ---- Code Health Classification ----
            code_lvl = classify_code(
                mc1,
                mc2,
                mv,
                rv,
                lv,
                sw_ver
            )

            # ---- PSP Classification ----
            # PSP = ProSupport Plus + ProSupport One
            # Based only on Support Max Contract Desc
            psp_desc = (
                val(support_max_desc_col)
                if support_max_desc_col is not None else ""
            )
            a_psp = is_psp_from_desc(psp_desc)

            category = get_category(pf, pn)

            # ---- Connection Status for asset table ----
            # This is only for displaying the individual asset's
            # connection status in the Assets table.
            cn = scan_connectivity(row)

            # Dashboard asset total must come only from primary Assets sheet
            if is_primary:
                total_hw += 1

            ws.cell(row=rn, column=cat_ci, value=category)

            # ============================================
            # HARDWARE / SOFTWARE COUNTS
            # ONLY the primary Assets sheet is allowed
            # to contribute to dashboard HW/SW totals.
            # Product Type is taken directly from Assets.
            # ============================================

            if is_primary:

                if is_hw:

                    s_hw += 1

                else:

                    s_sw += 1

            else:

                # Secondary sheets must NOT affect
                # dashboard Hardware / Software totals.
                pass

            if is_inst:

                ins_n += 1

            else:

                nins_n += 1

            # ---- Support classification ----

            has_sup_data = False

            if support_desc and support_desc not in ("None", ""):

                sup_type = classify_support_from_desc(support_desc)

                has_sup_data = True

            else:

                txt = " ".join(str(c or "") for c in row).upper()

                if "MULTI VENDOR" in txt or "MULTIVENDOR" in txt or "MULTI-VENDOR" in txt:

                    sup_type = "Multi Vendor Support"

                elif "PROSUPPORT ONE" in txt:

                    sup_type = "ProSupport Plus / PSONE"

                elif "PROSUPPORT PLUS" in txt:

                    sup_type = "ProSupport Plus"

                elif "PROSUPPORT" in txt:

                    sup_type = "ProSupport"

                else:

                    sup_type = "Basic Support / Warranty"

            if sup_type == "Multi Vendor Support":

                # MVS must never be replaced by contract-expiry status.
                final_sup = "Multi Vendor Support"

            else:

                cstatus = determine_contract_status(contract_end, today)

                if cstatus == "expiring":

                    final_sup = "Contract Expiring"

                elif cstatus == "expired":

                    final_sup = "Contract Expired"

                else:

                    final_sup = sup_type

            # ============================================

            # ✅ CRITICAL FIX: Only PRIMARY sheet assets

            # feed the Category / Support / Code breakdown

            # matrices. Assets found ONLY in secondary

            # sheets (e.g. "Expired Assets") are counted

            # in totals/contract summary above, but

            # excluded from entitlement & code tables [1]

            # ============================================

            if is_primary:

                cc[category] += 1

                if is_hw:

                    ins_cc[category] += 0  # placeholder, real below

                if is_inst:

                    ins_cc[category] += 1

                else:

                    nins_cc[category] += 1

                sm_all[final_sup][category] += 1

                if is_inst:

                    ins_sm[final_sup][category] += 1

                else:

                    nins_sm[final_sup][category] += 1

                cm_all[code_lvl][category] += 1

                if is_inst:

                    ins_cm[code_lvl][category] += 1

                else:

                    nins_cm[code_lvl][category] += 1

                if a_psp:

                    psp_n += 1

                    psp_cc[category] += 1

                    psp_sm[final_sup][category] += 1

                    psp_cm[code_lvl][category] += 1

                else:

                    npsp_n += 1

                    npsp_cc[category] += 1

                    npsp_sm[final_sup][category] += 1

                    npsp_cm[code_lvl][category] += 1

            

            p = pn[:50] if pn else "Unknown"

            prod_cnt[p] = prod_cnt.get(p, 0) + 1

            if len(assets) < 5000:

                asset_entry = {

                    # Existing compact fields
                    "s": sn[:15],
                    "a": aid[:20] if aid else "",

                    "p": pn[:50] if pn else "",
                    "f": pf[:25] if pf else "",

                    "c": category,
                    "st": final_sup,
                    "psp": a_psp,

                    "m": mc1[:25] if mc1 else "N/A",
                    "m2": mc2[:15] if mc2 else "",
                    "r": rv[:30] if rv else "N/A",

                    "cl": code_lvl,

                    "l": loc[:30] if loc else "",

                    "cn": cn[:15] if cn else "",
                    "ins": is_inst,

                    "sv": sw_ver[:25] if sw_ver else "",

                    "hw": is_hw,

                    "ce": contract_end[:10] if contract_end else "",

                    "has_support": has_sup_data,

                    "counted": is_primary,


                    # ========================================================
                    # FULL ASSET DATA FOR EML EXPORT
                    # ========================================================

                    "assetId":
                        aid[:50] if aid else "",

                    "productName":
                        pn[:100] if pn else "",

                    "shipDate":
                        val(ship_date_col)[:30]
                        if ship_date_col is not None
                        else "",

                    "supportActiveContractDesc":
                        val(support_desc_col)[:100]
                        if support_desc_col is not None
                        else "",

                    "lastContractEndDate":
                        contract_end[:30] if contract_end else "",

                    "locationId":
                        val(location_id_col)[:50]
                        if location_id_col is not None
                        else "",

                    "locationName":
                        loc[:100] if loc else "",

                    "address":
                        val(address_col)[:250]
                        if address_col is not None
                        else "",

                }

                if aid and aid not in ("None", ""):

                    asset_index[aid] = len(assets)

                assets.append(asset_entry)

        sheets.append({"name": sn, "hardware": s_hw, "software": s_sw,

                       "skipped": s_skip, "duplicates": s_dup, "primary": is_primary})

        print(f"[DONE] '{sn}': {s_hw} HW, {s_sw} SW, {s_skip} skip, {s_dup} dup")

        if has_microcode or has_support_desc:

            print(f"[UPDATE STATS] code_success={upd_success}, "

                  f"support_success={upd_support_success}")

    out_path = os.path.join(OUTPUT_FOLDER, f"categorized_{filename}")

    wb.save(out_path)

    tp = "N/A"

    tp_n = 0

    if prod_cnt:

        tp = max(prod_cnt, key=prod_cnt.get)

        tp_n = prod_cnt[tp]

    # Connectivity percentage:
    # Connected / (Connected + Eligible - Not Active)

    connectivity_total = (
        conn["connected"] +
        conn["eligible"]
    )

    cp = (
        round(
            conn["connected"] / connectivity_total * 100,
            1
        )
        if connectivity_total > 0
        else 0
    )

    print(f"\n{'='*60}")

    print(f"[FINAL] Total Assets: {total_hw} (HW:{hw_count}, SW:{sw_count})")

    print(f"[FINAL] Duplicates: {dup_count}")

    print(f"[FINAL] Active: {active_n}, Expired(T&M): {tm_n}")

    print(f"[FINAL] Counted (primary-sheet) sum of categories: {sum(cc.values())}")

    print(f"{'='*60}\n")

    data = {

        "total_hw": total_hw, "cc": cc, "sm": sm_all, "cm": cm_all,

        "conn": conn,
        "conn_history": conn_history,
        "cp": cp,
        "tp": tp,
        "tp_n": tp_n,

        "assets": assets, "sheets": sheets, "fn": filename, "cust": customer,

        "out": out_path, "psp_n": psp_n, "npsp_n": npsp_n,

        "psp_cc": psp_cc, "npsp_cc": npsp_cc,

        "psp_sm": psp_sm, "npsp_sm": npsp_sm,

        "psp_cm": psp_cm, "npsp_cm": npsp_cm,

        "dup_count": dup_count, "ins_n": ins_n, "nins_n": nins_n,

        "ins_cc": ins_cc, "nins_cc": nins_cc,

        "ins_sm": ins_sm, "nins_sm": nins_sm,

        "ins_cm": ins_cm, "nins_cm": nins_cm,

        "hw_count": hw_count, "sw_count": sw_count,

        "active_n": active_n, "tm_n": tm_n,

        "health_risks": health_risks,

        "software_health_total": software_health_total,

        "capacity": capacity,

    }

    # Store routing metadata alongside the existing report data.
    # This does not change any existing metric/calculation fields.
    data["report_type"] = report_type
    data["client_mode"] = (report_type == "client")

    fid = save_data(data)

    session["did"] = fid
    session["report_type"] = report_type

    # Client uploads go only to the Client dashboard.
    if report_type == "client":
        return redirect(url_for("client_dashboard"))

    # Existing Enterprise/standard flow remains exactly the same.
    return redirect(url_for("dashboard"))

@app.route("/client-dashboard")
def client_dashboard():

    d = load_data(session.get("did"))

    if not d:
        return redirect(url_for("home"))

    # Prevent an Enterprise report from accidentally using the Client UI.
    if not d.get("client_mode"):
        return redirect(url_for("dashboard"))

    # The current Client dashboard template expects client_activity to exist.
    # Older/current app.py processing does not calculate this field, so provide
    # a safe empty object for the UI without changing any existing metrics or
    # workbook-processing logic.
    d.setdefault("client_activity", {})

    return render_template("client_dashboard.html", d=d)

@app.route("/health-risk/<category>", methods=["GET", "POST"])
def health_risk(category):

    d = load_data(session.get("did"))

    if not d:
        return redirect(url_for("home"))

    if category not in CATEGORIES:
        return "Invalid category", 400

    health_risks = d.get("health_risks", {})

    # ============================================
    # SAVE ALL 4 HEALTH RISK VALUES
    # ============================================

    if request.method == "POST":

        try:
            security_advisories = int(
                request.form.get("security_advisories", 0)
            )

            field_change_order = int(
                request.form.get("field_change_order", 0)
            )

            pfn_pfr = int(
                request.form.get("pfn_pfr", 0)
            )

            aiops_health = int(
                request.form.get("aiops_health", 0)
            )

            # Do not allow negative numbers
            if (
                security_advisories < 0
                or field_change_order < 0
                or pfn_pfr < 0
                or aiops_health < 0
            ):
                raise ValueError

        except (ValueError, TypeError):
            return "Please enter valid numbers", 400

        # Create category if it does not exist
        if category not in health_risks:
            health_risks[category] = {
                r: 0 for r in HEALTH_RISK_TYPES
            }

        # Save all four values for the selected category
        health_risks[category][
            "Securities & Technical Advisories"
        ] = security_advisories

        health_risks[category][
            "Field Change Order"
        ] = field_change_order

        health_risks[category][
            "PFN/PFR"
        ] = pfn_pfr

        health_risks[category][
            "AIOPs Health"
        ] = aiops_health

        # Put updated health-risk data back into session data
        d["health_risks"] = health_risks

        # Save to existing session
        update_session_data(d)

        # Return to dashboard
        return redirect(url_for("dashboard"))

    # ============================================
    # DISPLAY FORM
    # ============================================

    return render_template(
        "health_risk.html",
        category=category,
        risk_types=HEALTH_RISK_TYPES,
        values=health_risks.get(
            category,
            {r: 0 for r in HEALTH_RISK_TYPES}
        )
    )

@app.route("/capacity/<category>", methods=["GET", "POST"])
def capacity_input(category):

    d = load_data(session.get("did"))

    if not d:
        return redirect(url_for("home"))

    # Only Storage and Data Protection are allowed
    if category not in CAPACITY_CATEGORIES:
        return "Invalid capacity category", 400

    capacity = d.get("capacity", {})

    # ============================================
    # SAVE CAPACITY VALUE
    # ============================================

    if request.method == "POST":

        try:
            array_usage = int(
                request.form.get("array_usage", 0)
            )

            if array_usage < 0:
                raise ValueError

        except (ValueError, TypeError):
            return "Please enter a valid number", 400

        # Create category if it does not exist
        if category not in capacity:
            capacity[category] = {
                "Array Usage > 80%": 0
            }

        # Save value
        capacity[category]["Array Usage > 80%"] = array_usage

        # Save back to session data
        d["capacity"] = capacity

        update_session_data(d)

        return redirect(url_for("dashboard"))

    # ============================================
    # DISPLAY FORM
    # ============================================

    return render_template(
        "capacity.html",
        category=category,
        value=capacity.get(
            category,
            {}
        ).get("Array Usage > 80%", 0)
    )

@app.route("/dashboard")

def dashboard():

    d = load_data(session.get("did"))

    if not d:

        return redirect(url_for("home"))

    return render_template("dashboard.html", d=d)

@app.route("/export-eml")
def export_eml():

    d = load_data(session.get("did"))

    if not d:
        return redirect(url_for("home"))

    # Client dashboard -> Client EML editor.
    # Enterprise/standard reports continue using the existing editor.
    if d.get("client_mode"):
        # Keep the Client EML template safe when the current app.py does not
        # provide optional client_activity data.
        d.setdefault("client_activity", {})
        return render_template(
            "client_eml_editor.html",
            d=d
        )

    return render_template(
        "eml_editor.html",
        d=d
    )

@app.route("/download")

def download():

    d = load_data(session.get("did"))

    if not d:

        return "No file", 404

    f = d.get("out", "")

    if not f or not os.path.exists(f):

        return "No file", 404

    return send_file(f, as_attachment=True)

if __name__ == "__main__":

    print("=" * 50)

    print("  QBR Automation Tool")

    print("  http://127.0.0.1:5000")

    print("=" * 50)

    app.run(debug=True, port=5000)