from __future__ import annotations
import sys; sys.path.append("..")
import time
import hashlib
import io
import json
import os
import tempfile
from typing import List, Tuple
import requests
import urllib3
from flask import Blueprint, Response, request
from sqlalchemy.orm import joinedload
from manager.S3Manager import S3Manager
from manager.MysqlManager import MysqlManager
from models.deal_deal import DealDeal
from models.base_base import BaseBase
from models.option_opti import OptionOpti
from config.MauiConfig import EECCX_TOKEN_URL, EECCX_API_URL, EECCX_CLIENT_ID, EECCX_CLIENT_SECRET, EECCX_CF_CLIENT_ID, EECCX_CF_CLIENT_SECRET, EECCX_HDL_NR, EECCX_PROV_HDL_NR

# A warning is disabled so self-signed certificate usage does not flood the log in development environments.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# The constant TOKEN_URL stores the identity provider endpoint used for OAuth authentication.
TOKEN_URL        = EECCX_TOKEN_URL

# The constant API_URL stores the partner API endpoint that generates the PDF.
API_URL          = EECCX_API_URL

# The constant CLIENT_ID stores the OAuth client identifier for the back-end application.
CLIENT_ID        = EECCX_CLIENT_ID

# The constant CLIENT_SECRET stores the OAuth client secret matching the client identifier.
CLIENT_SECRET    = EECCX_CLIENT_SECRET

# The constant CF_CLIENT_ID stores the Cloudflare Access client identifier required by the partner API.
CF_CLIENT_ID     = EECCX_CF_CLIENT_ID

# The constant CF_CLIENT_SECRET stores the Cloudflare Access client secret required by the partner API.
CF_CLIENT_SECRET = EECCX_CF_CLIENT_SECRET

# The constant HDL_NR stores the dealer number that must be supplied in every partner API request.
HDL_NR           = EECCX_HDL_NR

# The constant PROV_HDL_NR stores the provisioning dealer number that must be supplied in every partner API request.
PROV_HDL_NR      = EECCX_PROV_HDL_NR

# The constant PRODUKT_KATEGORIE stores the fixed product category for the request payload.
PRODUKT_KATEGORIE = "O"

# The blueprint instance is created from the module name so the router can be registered once inside the WebManager.
blueprint = Blueprint(__name__.rsplit(".", 1)[-1], __name__)

# The S3Manager instance manages file uploads to the configured S3 bucket.
s3_manager = S3Manager()

# The function returns a JSON error response with the supplied message.
def _json_error(message: str) -> Response:

    # A JSON payload is assembled with status "ERROR" and the provided message.
    payload = json.dumps({"status": "ERROR", "message": message}, ensure_ascii=False)
    # Always return HTTP 200 so Cloudflare never swaps in its own 5XX error page.
    return Response(payload, status=200, mimetype="application/json")

# The function extracts the option identifiers from the query string and normalises comma-separated values into a list.
def _extract_options() -> List[str]:

    # The raw option list is read from the query argument list to handle multiple "options" parameters.
    raw = request.args.getlist("options")

    # The following conditional branch handles the special case in which a single comma-separated value has been supplied instead of repeated parameters.
    if len(raw) == 1 and "," in raw[0]:

        # The value is split on commas and stripped so empty strings are removed from the final list.
        return [opt.strip() for opt in raw[0].split(",") if opt.strip()]
    return [opt for opt in raw if opt]

# The function builds a unique hash from tarif_id, the option list, and the current timestamp so the uploaded PDF file name is collision-free.
def _hash_id_options(tarif_id: str, options: List[str]) -> str:

    # The key string concatenates tarif_id, the sorted option list, and the current Unix timestamp.
    key = f"{tarif_id}:{','.join(sorted(options))}:{int(time.time())}".encode("utf-8")
    return hashlib.sha256(key).hexdigest()

# ---------------------------- OAuth-Token ---------------------------------- #

# The function exchanges client credentials for an OAuth access token and returns the token together with an error message if something goes wrong.
def _get_token() -> Tuple[str | None, str | None]:

    # The payload dictionary contains the grant type and client credentials for the token request.
    payload = {
        "grant_type": "client_credentials",
        "client_id":  CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    }

    try:

        # A POST request is sent to the identity provider to obtain an access token.
        r = requests.post(TOKEN_URL, data=payload, verify=False, timeout=10)
        # r.raise_for_status() is intentionally omitted so that a 5XX from the IDP never propagates.
    except requests.exceptions.RequestException as exc:

        # An error tuple is returned when the HTTP request fails.
        return None, f"Token-Abruf fehlgeschlagen: {exc}"

    # The JSON response is parsed to extract the access token field.
    try:
        token = r.json().get("access_token")
    except ValueError:
        token = None

    # The following conditional branch returns an error tuple when the response does not contain an access token.
    if not token:
        return None, "Kein access_token im Token-Response."

    return token, None

# ------------------------- Partner-API-Aufruf ------------------------------ #

# The function assembles the partner API payload, performs the HTTP PUT request, and returns either the JSON result or an error message.
def _partner_api(token: str, tarif_id: str, options: List[str]) -> Tuple[dict | None, str | None]:

    # The headers dictionary includes OAuth, Cloudflare, and content-type information required by the partner API.
    headers = {
        "Authorization":            f"Bearer {token}",
        "CF-Access-Client-Id":      CF_CLIENT_ID,
        "CF-Access-Client-Secret":  CF_CLIENT_SECRET,
        "Content-Type":             "application/json",
    }

    try:

        # A database session is opened to load the requested deal together with its base tariff information.
        session = MysqlManager().getSession()
        deal_int = int(tarif_id)
        deal = (
            session
            .query(DealDeal)
            .options(joinedload(DealDeal.base))
            .filter(DealDeal.id_deal == deal_int)
            .one_or_none()
        )

        # The following conditional branch returns an error whenever the specified deal identifier cannot be found.
        if deal is None:
            return None, f"Deal {tarif_id} not found."

        # The base_obj variable holds the related base tariff for the selected deal.
        base_obj: BaseBase = deal.base

        # The following conditional branch validates that a provider code exists for the base tariff; otherwise an error is returned.
        if not base_obj or not base_obj.providercode_base:
            return None, f"Kein providercode_base für Deal id={tarif_id} gefunden."
        providercode_base_value = base_obj.providercode_base
        providercode_deal_value = deal.providercode_deal

    finally:

        # The database session is always closed to free resources regardless of success or failure.
        session.close()

    # The am_aktion_id variable is initialised with None and used only when the deal provider code contains digits.
    am_aktion_id: int | None = None

    # The following conditional branch extracts numeric characters from the deal provider code to build the am_aktion_id value.
    if providercode_deal_value:
        digits = "".join(filter(str.isdigit, providercode_deal_value))
        if digits:
            am_aktion_id = int(digits)

    # The service_codes list collects provider codes of selected options and their parent groups to pass them to the API.
    service_codes: List[str] = []
    try:

        # A database session is opened to translate option identifiers to provider codes.
        session = MysqlManager().getSession()
        for opt_id in options:

            # The following try/except converts the option identifier to an integer and skips invalid values.
            try:
                opt_int = int(opt_id)
            except ValueError:
                continue
            opt = (
                session.query(OptionOpti)
                .filter(OptionOpti.id_opti == opt_int)
                .one_or_none()
            )

            # The following conditional branch skips options without a provider code.
            if not opt or not opt.providercode_opti:
                continue

            service_codes.append(opt.providercode_opti)
            parent_code = opt.providercategory_opti

            # This loop climbs up the category hierarchy so parent group codes are included as service codes.
            while parent_code:
                parent_opt = (
                    session.query(OptionOpti)
                    .filter(OptionOpti.providercode_opti == parent_code)
                    .limit(1)
                    .one_or_none()
                )

                # The following conditional branch breaks the traversal when the parent option is missing or lacks a provider code.
                if not parent_opt or not parent_opt.providercode_opti:
                    break
                service_codes.append(parent_opt.providercode_opti)
                parent_code = parent_opt.providercategory_opti

    finally:

        # The database session is always closed to release connections.
        session.close()

    # The payload dictionary is prepared according to the partner API specification.
    payload = {
        "hdl_nr":            HDL_NR,
        "prov_hdl_nr":       PROV_HDL_NR,
        "tarif_id":          providercode_base_value,
        "produkt_kategorie": PRODUKT_KATEGORIE,
        "service_code":      service_codes,
    }

    # The following conditional branch adds the promotional action identifier when one has been detected.
    if am_aktion_id is not None:
        payload["am_aktion_id"] = am_aktion_id

    try:

        # A PUT request is sent to the partner API with the assembled payload and headers.
        r = requests.put(API_URL, headers=headers, json=payload, verify=False, timeout=30)
        # r.raise_for_status() is deliberately omitted to suppress 5XX propagation.
    except requests.exceptions.RequestException as exc:

        # An error tuple is returned when the HTTP request fails.
        return None, f"API-Aufruf fehlgeschlagen: {exc} – Payload: {payload}"

    # The JSON body of the HTTP response is parsed.
    try:
        data = r.json()
    except ValueError:
        data = None

    # The err_val variable is inspected for API-level error information that must be mapped to a user-friendly string.
    err_val = data.get("error") if isinstance(data, dict) else None

    # The following conditional branch returns an error tuple when the API embedded error information in its JSON body.
    if err_val:
        if isinstance(err_val, list):
            msg = "; ".join(str(e) for e in err_val)
        elif isinstance(err_val, dict):
            msg = "; ".join(f"{k}: {v}" for k, v in err_val.items())
        elif isinstance(err_val, str):
            try:
                decoded = json.loads(err_val)
                msg = decoded.get("message", str(decoded)) if isinstance(decoded, dict) else str(decoded)
            except ValueError:
                msg = err_val
        else:
            msg = str(err_val)
        return None, msg

    # If the partner API did not return JSON or delivered a non-200 status, treat it as success with raw content.
    if not isinstance(data, dict):
        return {"raw_response": r.text, "status_code": r.status_code}, None

    return data, None

# ----------------------------- PDF-Download ------------------------------- #

# The function downloads the PDF at the supplied URL and returns its binary content together with an optional error message.
def _download_pdf(url: str) -> Tuple[bytes | None, str | None]:

    # A streaming GET request is performed so large files do not exhaust memory unnecessarily.
    try:
        r = requests.get(url, stream=True, verify=False, timeout=30)
        # r.raise_for_status() intentionally omitted for the same reason as above.
    except requests.exceptions.RequestException as exc:

        # A tuple containing None and an error message is returned when the HTTP request fails.
        return None, f"PDF-Download fehlgeschlagen: {exc}"
    return r.content, None

# The route handler generates the EECCX PDF for a given deal and optional option list and returns the public S3 download URL.
@blueprint.route("/freenet-eeccx/<string:tarif_id>", methods=["GET"])
def eeccx_pdf(tarif_id: str):

    try:
        # The options list is extracted from the query string so it can be forwarded to the partner API.
        options = _extract_options()

        # The OAuth token is obtained and an error response is returned when token retrieval fails.
        token, err = _get_token()
        if err:
            return _json_error(err)

        # The partner API is called and an error response is returned when the API invocation fails.
        api_json, err = _partner_api(token, tarif_id, options)
        if err:
            return _json_error(err)

        # The pdf_url variable tries to extract the PCS or PCI PDF link from the partner API response JSON.
        pdf_url = api_json.get("pcsPdf") or api_json.get("pciPdf") if isinstance(api_json, dict) else None

        # The following conditional branch returns an error response when no PDF URL is present in the API response.
        if not pdf_url:
            msg = (api_json.get("message") if isinstance(api_json, dict) else None) or "Keine PDF-URL in der API-Antwort."
            return _json_error(msg)

        # The PDF is downloaded and an error response is returned when the download fails.
        pdf_bytes, err = _download_pdf(pdf_url)
        if err:
            return _json_error(err)

        # A unique hash is generated so the temporary file and the S3 object name are collision-free.
        hash_name = _hash_id_options(tarif_id, options)

        # A temporary file is opened so the PDF can be written to disk for uploading.
        with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
            tmp.write(pdf_bytes)
            tmp_path = tmp.name

        # The PDF file is uploaded to S3 and the local temporary file is removed afterwards.
        s3_key = f"eeccx/{hash_name}.pdf"
        url = s3_manager.uploadFile(tmp_path, s3_key)
        os.remove(tmp_path)

        # The following conditional branch returns an error response when the S3 upload fails.
        if not url:
            return _json_error(f"Upload zu S3 fehlgeschlagen für key={s3_key}")

        # A JSON response is returned containing the public URL of the uploaded PDF.
        payload = json.dumps({"url": url}, ensure_ascii=False)
        return Response(payload, status=200, mimetype="application/json")

    except Exception as exc:
        # Catch-all to ensure absolutely no unhandled exception leaks a non-200 status.
        return _json_error(f"Unerwarteter Fehler: {exc}")
