"""
/***************************************************************************
 LDMP - A QGIS plugin
 This plugin supports monitoring and reporting of land degradation to the UNCCD
 and in support of the SDG Land Degradation Neutrality (LDN) target.
                              -------------------
        begin                : 2017-05-23
        git sha              : $Format:%H$
        copyright            : (C) 2017 by Conservation International
        email                : trends.earth@conservation.org
 ***************************************************************************/
"""

import base64
import gzip
import io
import json
import sys
import time
from urllib.parse import quote_plus

import backoff
import requests
from qgis.core import (
    Qgis,
    QgsApplication,
    QgsNetworkAccessManager,
    QgsNetworkReplyContent,
    QgsSettings,
    QgsTask,
)
from qgis.PyQt import QtCore, QtNetwork

from . import auth, conf
from .constants import TIMEOUT, get_api_url
from .logger import log


def get_client_header():
    """Build the X-TE-Client header value for API requests.

    Returns:
        str: Header value in format
            "type=qgis_plugin; version=X.X.X; qgis_version=X.X.X; os=OS; lang=XX"
    """
    try:
        from . import __version__
    except ImportError:
        __version__ = "unknown"

    # Get QGIS version
    try:
        qgis_version = Qgis.QGIS_VERSION
    except Exception:
        qgis_version = "unknown"

    # Get OS name
    platform_map = {
        "win32": "Windows",
        "darwin": "macOS",
        "linux": "Linux",
        "freebsd": "FreeBSD",
    }
    os_name = platform_map.get(sys.platform, sys.platform)

    # Get user's language from QGIS locale (e.g. "en" from "en_US")
    try:
        locale = QgsApplication.locale()
        lang = locale.split("_")[0] if locale else "en"
    except Exception:
        lang = "en"

    return (
        f"type=qgis_plugin; version={__version__}; qgis_version={qgis_version}; "
        f"os={os_name}; lang={lang}"
    )


class tr_api:
    @staticmethod
    def tr(message):
        return QtCore.QCoreApplication.translate("tr_api", message)


###############################################################################
# Threading functions for calls to requests


class RequestTask(QgsTask):
    # Compress request bodies larger than this size (in bytes)
    COMPRESSION_THRESHOLD = 1024  # 1KB
    # How often to check for cancellation during network requests (ms)
    CANCEL_CHECK_INTERVAL_MS = 100

    def __init__(
        self,
        description,
        url,
        method,
        payload,
        headers,
        timeout=30,
    ):
        super().__init__(description, QgsTask.CanCancel | QgsTask.Silent)

        self.description = description
        self.url = url
        self.method = method
        self.payload = payload

        self.timeout = timeout
        self.headers = headers or {}
        self.exception = None
        self.resp = None
        self._timed_out = False
        self._reply = None  # Store QNetworkReply for cancellation

    def cancel(self):
        """Cancel the task and abort any in-progress network request."""
        if self._reply is not None:
            try:
                self._reply.abort()
            except RuntimeError:
                pass  # Reply already deleted
        return super().cancel()

    def _wait_for_reply(self, reply):
        """
        Wait for a QNetworkReply to finish with timeout and cancellation support.

        Args:
            reply: The QNetworkReply to wait for

        Returns:
            True if completed successfully, False if cancelled or timed out
        """
        self._reply = reply
        self._timed_out = False

        loop = QtCore.QEventLoop()
        reply.finished.connect(loop.quit)

        # Timer for timeout
        timeout_timer = QtCore.QTimer()
        timeout_timer.setSingleShot(True)
        timeout_timer.timeout.connect(lambda: self._on_timeout(loop))
        timeout_timer.start(self.timeout * 1000)

        # Timer for periodic cancellation checks
        cancel_timer = QtCore.QTimer()
        cancel_timer.timeout.connect(
            lambda: self._check_cancelled(loop, reply, timeout_timer, cancel_timer)
        )
        cancel_timer.start(self.CANCEL_CHECK_INTERVAL_MS)

        loop.exec()

        # Cleanup timers
        timeout_timer.stop()
        cancel_timer.stop()

        self._reply = None
        return not self._timed_out and not self.isCanceled()

    def _check_cancelled(self, loop, reply, timeout_timer, cancel_timer):
        """Check if task was cancelled and abort if so."""
        if self.isCanceled():
            log(f"Request cancelled for {self.method.upper()} {self.url}")
            timeout_timer.stop()
            cancel_timer.stop()
            try:
                reply.abort()
            except RuntimeError:
                pass
            loop.quit()

    def _on_timeout(self, loop):
        """Called when the QTimer fires for custom-method event loops."""
        self._timed_out = True
        log(
            f"Request timed out after {self.timeout}s for "
            f"{self.method.upper()} {self.url}"
        )
        if self._reply is not None:
            try:
                self._reply.abort()
            except RuntimeError:
                pass
        loop.quit()

    def _compress_request_if_needed(self, request_data, network_request):
        """
        Compress request data if it exceeds the threshold.

        Args:
            request_data: The raw request data bytes
            network_request: The QNetworkRequest to add headers to

        Returns:
            Compressed or original request data
        """
        if len(request_data) > self.COMPRESSION_THRESHOLD:
            original_size = len(request_data)
            request_data = gzip.compress(request_data)
            compressed_size = len(request_data)
            compression_ratio = (1 - compressed_size / original_size) * 100
            log(
                f"Compressed request body: {original_size} -> {compressed_size} bytes "
                f"({compression_ratio:.1f}% reduction)"
            )
            network_request.setRawHeader(
                QtCore.QByteArray(b"Content-Encoding"), QtCore.QByteArray(b"gzip")
            )
        return request_data

    def run(self):
        try:
            # Check if already cancelled before starting
            if self.isCanceled():
                return False

            qurl = QtCore.QUrl(self.url)

            network_manager = QgsNetworkAccessManager().instance()
            # Use the configured timeout instead of hardcoded 10 minutes
            network_manager.setTimeout(self.timeout * 1000)

            network_request = QtNetwork.QNetworkRequest(qurl)

            # Disable HTTP/2 — Qt6 enables it by default
            try:
                network_request.setAttribute(
                    QtNetwork.QNetworkRequest.Attribute.Http2AllowedAttribute, False
                )
            except AttributeError:
                try:
                    network_request.setAttribute(
                        QtNetwork.QNetworkRequest.Http2AllowedAttribute, False
                    )
                except AttributeError:
                    pass

            # Set content type first
            network_request.setHeader(
                QtNetwork.QNetworkRequest.ContentTypeHeader, "application/json"
            )

            if len(self.headers) > 0:
                for header_name, header_value in self.headers.items():
                    network_request.setRawHeader(
                        QtCore.QByteArray(header_name.encode()),
                        QtCore.QByteArray(header_value.encode()),
                    )

            if self.method == "get":
                # Use non-blocking get() instead of blockingGet() for cancellation
                reply = network_manager.get(network_request)
                if not self._wait_for_reply(reply):
                    self.resp = None
                    return False
                self.resp = reply

            elif self.method == "post":
                request_data = None
                doc = QtCore.QJsonDocument({})

                if self.payload is not None:
                    # Work around to handle QJsonDocument failing to deal with
                    # dictionaries that contain nested values of OrderedDict.
                    try:
                        doc = QtCore.QJsonDocument(self.payload)
                    except TypeError:
                        request_data = bytes(json.dumps(self.payload), encoding="utf-8")

                request_data = (
                    doc.toJson(QtCore.QJsonDocument.Compact)
                    if request_data is None
                    else request_data
                )

                # Compress large request bodies
                request_data = self._compress_request_if_needed(
                    request_data, network_request
                )

                # Use non-blocking post() instead of blockingPost() for cancellation
                reply = network_manager.post(network_request, request_data)
                if not self._wait_for_reply(reply):
                    self.resp = None
                    return False
                self.resp = reply

            elif self.method == "update":
                doc = QtCore.QJsonDocument(self.payload)
                request_data = doc.toJson(QtCore.QJsonDocument.Compact)

                # Compress large request bodies
                request_data = self._compress_request_if_needed(
                    request_data, network_request
                )

                reply = network_manager.sendCustomRequest(
                    network_request, b"UPDATE", request_data
                )
                if not self._wait_for_reply(reply):
                    self.resp = None
                    return False
                self.resp = reply

            elif self.method == "delete":
                empty_payload = {}
                doc = QtCore.QJsonDocument(empty_payload)
                request_data = doc.toJson(QtCore.QJsonDocument.Compact)

                reply = network_manager.sendCustomRequest(
                    network_request, b"DELETE", request_data
                )
                if not self._wait_for_reply(reply):
                    self.resp = None
                    return False
                self.resp = reply

            elif self.method == "patch":
                doc = QtCore.QJsonDocument(self.payload)
                request_data = doc.toJson(QtCore.QJsonDocument.Compact)

                # Compress large request bodies
                request_data = self._compress_request_if_needed(
                    request_data, network_request
                )

                reply = network_manager.sendCustomRequest(
                    network_request, b"PATCH", request_data
                )
                if not self._wait_for_reply(reply):
                    self.resp = None
                    return False
                self.resp = reply

            elif self.method == "head":
                reply = network_manager.head(network_request)
                if not self._wait_for_reply(reply):
                    self.resp = None
                    return False
                self.resp = reply

            else:
                self.exception = ValueError(
                    "Unrecognized method: {}".format(self.method)
                )
                return False
        except Exception as exc:
            self.exception = exc
            return False

        # Snapshot values needed by finished() as finished() runs on the
        # main thread where the reply may already have been deleted.
        if self.resp is not None:
            self._resp_error_code = self.resp.error()
            self._resp_status_code = self.resp.attribute(
                QtNetwork.QNetworkRequest.HttpStatusCodeAttribute
            )

        return True

    def finished(self, result):
        if result:
            # Task completed successfully
            pass
        else:
            if self.exception is None:
                log(f"API {self.method} not successful - probably cancelled")
            else:
                try:
                    log(
                        f"API {self.method} not successful - exception: {self.exception}"
                    )
                    raise self.exception
                except requests.exceptions.ConnectionError:
                    self.error_message = tr_api.tr(
                        "Unable to login to Trends.Earth server. Check your "
                        "internet connection."
                    )
                except requests.exceptions.Timeout:
                    self.error_message = tr_api.tr(
                        "Unable to connect to Trends.Earth server."
                    )

        if self.resp is not None:
            error_code = self._resp_error_code
            status_code = self._resp_status_code

            log(
                f'API response from "{self.method}" request: error={error_code}, status={status_code}'
            )

            # If error code 204 (AuthenticationRequiredError), provide more details
            if error_code == 204:
                log(
                    "Error 204: AuthenticationRequiredError - authentication credentials were not accepted"
                )
        else:
            log(f'API response from "{self.method}" request was None')


###############################################################################
# Other helper functions for api calls


def backoff_hdlr(details):
    log(
        "Backing off {wait:0.1f} seconds after {tries} tries "
        "calling function {target}".format(**details)
    )


class APIClient(QtCore.QObject):
    url: str

    # Signal emitted when authentication fails, with error message
    authentication_failed = QtCore.pyqtSignal(str)

    def __init__(self, url, timeout=30, parent=None):
        super().__init__(parent)
        self.url = url
        self.timeout = timeout
        self._login_mutex = QtCore.QMutex()
        # In-memory cache for tokens to avoid authManager access from background threads
        self._cached_access_token = None
        self._cached_refresh_token = None
        # In-memory cache for credentials to avoid authManager access from background threads
        self._cached_username = None
        self._cached_password = None
        self._cached_auth_config_id = None

    def pre_cache_credentials(self):
        """Read credentials from auth manager and cache them for background thread use."""
        auth_config = auth.get_auth_config(auth.TE_API_AUTH_SETUP, warn=False)
        if auth_config:
            self._cached_username = auth_config.config("username")
            self._cached_password = auth_config.config("password")
            self._cached_auth_config_id = auth_config.id()
            log("Credentials pre-cached for background thread use")
        # Also pre-cache JWT tokens so background threads don't need to access
        # the non-thread-safe QgsApplication.authManager() to retrieve them.
        access_token, refresh_token = auth.get_jwt_tokens()
        if access_token is not None:
            self._cached_access_token = access_token
            self._cached_refresh_token = refresh_token
            log("JWT tokens pre-cached for background thread use")

    def _decode_jwt_payload(self, token):
        """Decode JWT payload to extract expiration time"""
        try:
            # JWT tokens have 3 parts separated by dots: header.payload.signature
            parts = token.split(".")
            if len(parts) != 3:
                log("Invalid JWT token format")
                return None

            # Decode the payload (second part)
            payload = parts[1]
            # Add padding if needed (JWT base64 encoding may not have padding)
            padding = 4 - len(payload) % 4
            if padding != 4:
                payload += "=" * padding

            decoded_bytes = base64.urlsafe_b64decode(payload)
            payload_data = json.loads(decoded_bytes.decode("utf-8"))

            return payload_data
        except Exception as e:
            log(f"Error decoding JWT payload: {e}")
            return None

    def _is_token_expired(self, token, buffer_seconds=300):
        """Check if JWT token is expired or will expire within buffer_seconds"""
        payload = self._decode_jwt_payload(token)
        if not payload:
            return True

        exp = payload.get("exp")
        if not exp:
            log("JWT token missing expiration claim")
            return True

        # Check if token expires within the buffer time (default 5 minutes)
        current_time = time.time()
        return current_time >= (exp - buffer_seconds)

    def _store_tokens(self, access_token, refresh_token=None):
        """Store access and refresh tokens using QGIS Auth Manager (encrypted)."""
        # Update in-memory cache first
        self._cached_access_token = access_token
        self._cached_refresh_token = refresh_token
        # Then store in authManager (safe to call from main thread)
        auth.store_jwt_tokens(access_token, refresh_token)

    def _get_stored_tokens(self):
        """Retrieve stored access and refresh tokens.

        Uses in-memory cache if available (safe for background threads),
        otherwise retrieves from QGIS Auth Manager (main thread only).
        """
        # If we have cached tokens, use them (safe from any thread)
        if self._cached_access_token is not None:
            return self._cached_access_token, self._cached_refresh_token

        # Otherwise, retrieve from authManager (only safe from main thread)
        # The auth.get_jwt_tokens() function has thread safety check built in
        access_token, refresh_token = auth.get_jwt_tokens()

        # Cache the tokens if we got them successfully
        if access_token is not None:
            self._cached_access_token = access_token
            self._cached_refresh_token = refresh_token

        return access_token, refresh_token

    def _clear_stored_tokens(self):
        """Clear stored tokens from QGIS Auth Manager and in-memory cache.

        Only token data is cleared here.  The in-memory credential cache
        (username / password) is intentionally preserved so that background
        threads can still perform a fresh login after a token expires or a
        refresh attempt fails.  Credentials are wiped only by logout().
        """
        # Clear in-memory token cache only
        self._cached_access_token = None
        self._cached_refresh_token = None
        # Clear from authManager
        auth.clear_jwt_tokens()

    def _refresh_access_token(self, refresh_token):
        """Use refresh token to get new access token.

        The API implements refresh token rotation: the presented refresh token
        is revoked and a new one is issued.  Both the new access token and the
        new refresh token are stored; the old refresh token must not be reused.
        """
        if not refresh_token:
            log("No refresh token available")
            return None

        log("Attempting to refresh access token")

        resp = self.call_api(
            "/auth/refresh?legacy=false",
            method="post",
            payload={"refresh_token": refresh_token},
            use_token=False,  # Don't use token for refresh endpoint
        )

        if resp:
            access_token = resp.get("access_token")

            if access_token:
                # The API rotates the refresh token on every use — store the
                # new refresh token returned in the response (fall back to the
                # old one only if the server omits it, for resilience).
                new_refresh_token = resp.get("refresh_token") or refresh_token
                self._store_tokens(access_token, new_refresh_token)
                log("Access token refreshed successfully")
                return access_token
            else:
                log("No access token in refresh response")
        else:
            log("Failed to refresh access token")

        return None

    def login(self, authConfigId=None):
        self._login_mutex.lock()
        try:
            return self._login_impl(authConfigId)
        finally:
            self._login_mutex.unlock()

    def _login_impl(self, authConfigId=None):
        # First check if we have valid stored tokens
        stored_access_token, stored_refresh_token = self._get_stored_tokens()

        # If we have an access token and it's not expired, use it
        if stored_access_token and not self._is_token_expired(stored_access_token):
            return stored_access_token

        # If access token is expired but we have a refresh token, try to refresh
        if (
            stored_access_token
            and stored_refresh_token
            and self._is_token_expired(stored_access_token)
        ):
            log("Access token expired, attempting refresh")
            refreshed_token = self._refresh_access_token(stored_refresh_token)
            if refreshed_token:
                return refreshed_token
            else:
                log("Token refresh failed, clearing stored tokens and logging in fresh")
                self._clear_stored_tokens()

        # Fall back to fresh login - check cache first to avoid authManager from background threads
        if (
            self._cached_username
            and self._cached_password
            and (authConfigId is None or authConfigId == self._cached_auth_config_id)
        ):
            # Use cached credentials (safe from any thread)
            username = self._cached_username
            password = self._cached_password
            log("Using cached credentials for fresh login")
        else:
            # Need to retrieve credentials from authManager (main thread only)
            authConfig = auth.get_auth_config(
                auth.TE_API_AUTH_SETUP, authConfigId=authConfigId
            )

            if not authConfig:
                log("API unable to login - no auth configuration found")
                error_message = tr_api.tr(
                    "No login credentials configured. "
                    "Please set up your username and password in Trends.Earth settings."
                )
                self.authentication_failed.emit(error_message)
                return None

            username = authConfig.config("username")
            password = authConfig.config("password")

            # Cache credentials for future use (including from background threads)
            self._cached_username = username
            self._cached_password = password
            self._cached_auth_config_id = authConfigId

        if not username or not password:
            log(
                f"API unable to login - credentials incomplete "
                f"(username={'set' if username else 'missing'}, "
                f"password={'set' if password else 'missing'})"
            )
            error_message = tr_api.tr(
                "Unable to retrieve stored credentials. "
                "Please re-enter your username and password in Trends.Earth settings."
            )
            self.authentication_failed.emit(error_message)
            return None

        resp = self.call_api(
            "/auth",
            method="post",
            payload={
                "email": username,
                "password": password,
            },
            use_token=False,  # Don't use token for initial auth
        )

        error_message = ""
        if resp is not None:
            try:
                access_token = resp.get("access_token", None)
                refresh_token = resp.get("refresh_token", None)

                if access_token is None:
                    log("Unable to read Trends.Earth token in API response")
                    log(
                        f"Response keys: {list(resp.keys()) if resp else 'No response'}"
                    )

                    # Check if this is a 204 response (successful but no content)
                    if resp is not None and len(resp) == 0:
                        error_message = tr_api.tr(
                            "Authentication succeeded but no tokens returned. "
                            "The API authentication method may have changed."
                        )
                        ret = None
                    else:
                        error_message = tr_api.tr(
                            "Unable to read token for Trends.Earth "
                            "server. Check username and password."
                        )
                        ret = None
                else:
                    # Store both tokens
                    self._store_tokens(access_token, refresh_token)
                    ret = access_token

            except KeyError:
                log("API unable to login - check username and password")
                error_message = tr_api.tr(
                    "Unable to login to Trends.Earth. Check username and password."
                )
                ret = None
        else:
            log("Unable to access Trends.Earth server")
            error_message = tr_api.tr(
                "Unable to access Trends.Earth server. Check your internet connection"
            )
            ret = None

        if error_message:
            log(error_message)
            self.authentication_failed.emit(error_message)

        return ret

    def logout(self):
        """Logout user and revoke tokens"""
        access_token, refresh_token = self._get_stored_tokens()

        # Try to revoke tokens on server if we have them
        if access_token:
            try:
                # Call logout endpoint to revoke the token on server side
                resp = self.call_api("/auth/logout", method="post", use_token=True)
                if resp:
                    log("Server-side logout successful")
                else:
                    log("Server-side logout failed, but clearing local tokens anyway")
            except Exception as e:
                log(f"Error during server-side logout: {e}")

        # Always clear local tokens, credential cache, and cached user ID
        self._clear_stored_tokens()
        # Explicit credential cache wipe on logout (not done in _clear_stored_tokens
        # so that background threads can still re-login after token expiry).
        self._cached_username = None
        self._cached_password = None
        self._cached_auth_config_id = None

        # Clear cached user ID from settings
        try:
            conf.settings_manager.write_value(conf.Setting.USER_ID, None)
        except Exception as e:
            log(f"Warning: Could not clear cached user ID: {e}")

        # Remove QGIS authentication configuration to prevent conflicts with new logins
        try:
            auth.remove_current_auth_config(auth.TE_API_AUTH_SETUP)
            log("Removed QGIS authentication configuration")
        except Exception as e:
            log(f"Warning: Could not remove QGIS auth config: {e}")

        # Clear all auth-related settings to ensure clean state
        try:
            settings = QgsSettings()
            settings.setValue("trendsearth/auth", None)
            settings.setValue(f"trends_earth/{auth.TE_API_AUTH_SETUP.key}", None)

            log("Cleared Trends.Earth authentication settings")
        except Exception as e:
            log(f"Warning: Could not clear auth settings: {e}")

        log("User logged out and tokens cleared")

        return True

    def authenticate(self, email, password):
        """Attempt login and return detailed result including error information.

        Returns:
            tuple: (success: bool, error_info: dict | None)
                - success: True if login succeeded, False otherwise
                - error_info: None on success, or dict with error details:
                    - message: Human-readable error message
                    - error_code: Error code (e.g., 'account_locked')
                    - minutes_remaining: Minutes until account unlocks (if applicable)
                    - requires_password_reset: True if password reset is required
        """
        resp = self._make_request(
            "Trends.Earth Login",
            url=self.url + "/auth",
            method="post",
            payload={"email": email, "password": password},
            headers={},
            timeout=self.timeout,
        )

        if resp is None:
            return False, {
                "message": "Connection failed. Please check your internet connection."
            }

        status_code = resp.attribute(QtNetwork.QNetworkRequest.HttpStatusCodeAttribute)

        if status_code == 200:
            return True, None

        # Parse error response
        error_info = {"message": "Invalid username or password."}
        try:
            if type(resp) is QtNetwork.QNetworkReply:
                error_data = resp.readAll()
            elif type(resp) is QgsNetworkReplyContent:
                error_data = resp.content()
            else:
                error_data = None

            if error_data:
                import json

                error_text = bytes(error_data).decode("utf-8", errors="replace")
                error_body = json.loads(error_text)

                if isinstance(error_body, dict):
                    error_info = {
                        "message": error_body.get("message")
                        or error_body.get("msg", "Login failed."),
                        "error_code": error_body.get("error_code"),
                        "minutes_remaining": error_body.get("minutes_remaining"),
                        "requires_password_reset": error_body.get(
                            "requires_password_reset", False
                        ),
                    }
        except Exception as e:
            log(f"Failed to parse login error response: {e}")

        return False, error_info

    @backoff.on_predicate(
        backoff.expo, lambda x: x is None, max_tries=3, on_backoff=backoff_hdlr
    )
    def _make_request(self, description, **kwargs):
        api_task = RequestTask(description, **kwargs)
        QgsApplication.taskManager().addTask(api_task)
        result = api_task.waitForFinished((self.timeout + 1) * 1000)

        if not result:
            log("Request timed out")
            return None

        return api_task.resp

    def _clean_payload(self, payload):
        clean_payload = payload.copy()

        if "password" in clean_payload:
            clean_payload["password"] = "**REMOVED**"

        if "refresh_token" in clean_payload:
            clean_payload["refresh_token"] = "**REMOVED**"

        return clean_payload

    def call_api(self, endpoint, method="get", payload=None, use_token=False):
        return self._call_api_impl(
            endpoint, method=method, payload=payload, use_token=use_token
        )

    def _call_api_impl(
        self, endpoint, method="get", payload=None, use_token=False, _is_retry=False
    ):
        token = None
        if use_token:
            token = self.login()

            if token:
                headers = {"Authorization": f"Bearer {token}"}
            else:
                return None
        else:
            headers = {}

        # Add client identification header for all requests
        headers["X-TE-Client"] = get_client_header()

        # Only continue if don't need token or if token load was successful

        if (not use_token) or token:
            log('API calling {} with method "{}"'.format(endpoint, method))
            resp = self._make_request(
                "Trends.Earth API call",
                url=self.url + endpoint,
                method=method,
                payload=payload,
                headers=headers,
                timeout=self.timeout,
            )
        else:
            resp = None

        if resp is not None:
            status_code = resp.attribute(
                QtNetwork.QNetworkRequest.HttpStatusCodeAttribute
            )

            if status_code in [200, 201, 204]:  # Accept success status codes
                if status_code == 204:
                    # 204 No Content - successful but no response body
                    log(
                        'API response from "{}" request: {}'.format(method, status_code)
                    )
                    ret = {}  # Return empty dict for 204 responses
                elif type(resp) is QtNetwork.QNetworkReply:
                    raw = bytes(resp.readAll())
                    try:
                        encoding = bytes(resp.rawHeader(b"Content-Encoding")).decode(
                            "utf-8", errors="ignore"
                        )
                    except Exception:
                        encoding = ""
                    if "gzip" in encoding.lower():
                        try:
                            raw = gzip.decompress(raw)
                        except OSError:
                            pass
                    try:
                        ret = json.load(io.BytesIO(raw))
                    except json.JSONDecodeError as e:
                        log(f"Failed to parse JSON response: {e}")
                        log(f"Response content (first 500 chars): {raw[:500]}")
                        ret = None
                elif type(resp) is QgsNetworkReplyContent:
                    raw = bytes(resp.content())
                    try:
                        encoding = bytes(resp.rawHeader(b"Content-Encoding")).decode(
                            "utf-8", errors="ignore"
                        )
                    except Exception:
                        encoding = ""
                    if "gzip" in encoding.lower():
                        try:
                            raw = gzip.decompress(raw)
                        except OSError:
                            pass
                    try:
                        ret = json.load(io.BytesIO(raw))
                    except json.JSONDecodeError as e:
                        log(f"Failed to parse JSON response: {e}")
                        log(f"Response content (first 500 chars): {raw[:500]}")
                        ret = None
                else:
                    err_msg = "Unknown object type: {}.".format(str(resp))
                    log(err_msg)
                    ret = None
            else:
                desc, status = resp.error(), resp.errorString()

                # Try to read error response body for more details
                error_body = None
                error_text = None
                try:
                    if type(resp) is QtNetwork.QNetworkReply:
                        error_data = resp.readAll()
                        if error_data:
                            error_text = bytes(error_data).decode(
                                "utf-8", errors="replace"
                            )
                            error_body = json.loads(error_text)
                    elif type(resp) is QgsNetworkReplyContent:
                        error_data = resp.content()
                        if error_data:
                            error_text = bytes(error_data).decode(
                                "utf-8", errors="replace"
                            )
                            error_body = json.loads(error_text)
                except json.JSONDecodeError:
                    # Response is not JSON (e.g., HTML error page from 502)
                    if error_text:
                        log(
                            f"Non-JSON error response (first 500 chars): {error_text[:500]}"
                        )
                except Exception as e:
                    log(f"Could not parse error response body: {e}")

                # Provide user-friendly error messages for common transient errors
                if (
                    status_code == 403
                    and isinstance(error_body, dict)
                    and error_body.get("error_code") == "gee_terms_required"
                ):
                    err_msg = error_body.get(
                        "message",
                        tr_api.tr(
                            "This script requires Google Earth Engine. You must accept confirm "
                            "understanding of the GEE terms of use before running it. Please "
                            "update your profile in the Trends.Earth plugin settings to do so."
                        ),
                    )
                elif status_code in [502, 503, 504]:
                    # Bad Gateway, Service Unavailable, Gateway Timeout
                    err_msg = tr_api.tr(
                        "The Trends.Earth server is temporarily unavailable (error {status}). "
                        "This is usually a temporary issue. Please try again in a few moments."
                    ).format(status=status_code)
                elif status_code == 500:
                    # Internal Server Error
                    err_msg = tr_api.tr(
                        "The Trends.Earth server encountered an internal error (error 500). "
                        "Please try again. If the problem persists, contact the Trends.Earth team."
                    )
                elif status_code == 401 and use_token:
                    # Unauthorized - token issue
                    err_msg = tr_api.tr(
                        "Authentication failed. Please check your login credentials."
                    )
                else:
                    # Generic error message
                    err_msg = "Error: {} (status {}).".format(desc, status)
                    if error_body:
                        if isinstance(error_body, dict) and "msg" in error_body:
                            err_msg += f" Server message: {error_body['msg']}"
                        else:
                            err_msg += f" Server response: {error_body}"

                log(err_msg)

                # If we get a 401 (Unauthorized) error and we're using a token,
                # the token may be revoked, expired, or otherwise invalid.
                # Clear stored tokens and retry once with a fresh login.
                if status_code == 401 and use_token:
                    log("Received 401 error, clearing stored tokens for fresh login")
                    self._clear_stored_tokens()

                    if not _is_retry:
                        log("Retrying API call with fresh authentication")
                        return self._call_api_impl(
                            endpoint,
                            method=method,
                            payload=payload,
                            use_token=use_token,
                            _is_retry=True,
                        )

                ret = None
        else:
            ret = None

        return ret

    def get_header(self, url):
        resp = self._make_request(
            "Get head",
            url=url,
            method="head",
            payload=None,
            headers=None,
            timeout=self.timeout,
        )

        if resp is not None:
            status_code = resp.attribute(
                QtNetwork.QNetworkRequest.HttpStatusCodeAttribute
            )
            if status_code == 200:
                ret = resp
            else:
                desc, status = resp.error(), resp.errorString()
                err_msg = "Error: {} (status {}).".format(desc, status)
                log(err_msg)
                """
                iface.messageBar().pushCritical(
                    "Trends.Earth", "Error: {} (status {}).".format(desc, status)
                )
                """
                ret = None

            return ret

    ################################################################################
    # Functions supporting access to individual api endpoints

    def recover_pwd(self, email, legacy=False):
        """Request password recovery.

        Args:
            email: User's email address
            legacy: If True, uses legacy mode (password emailed directly).
                   If False (default), uses secure mode (reset link emailed).
        """
        endpoint = "/api/v1/user/{}/recover-password?legacy={}".format(
            quote_plus(email), "true" if legacy else "false"
        )
        return self.call_api(endpoint, "post")

    def get_user(self, email="me"):
        resp = self.call_api(
            "/api/v1/user/{}".format(quote_plus(email)), use_token=True
        )

        if resp:
            return resp["data"]
        else:
            return None

    def delete_user(self, email="me"):
        resp = self.call_api("/api/v1/user/me", "delete", use_token=True)

        if resp:
            return True
        else:
            return None

    def register(
        self,
        email,
        name,
        organization,
        country,
        legacy=False,
        role_title=None,
        sector=None,
        sector_other=None,
        gender_identity=None,
        gender_identity_description=None,
        gee_license_acknowledged=None,
        purpose_of_use=None,
        purpose_of_use_other=None,
    ):
        """Register a new user account.

        Args:
            email: User's email address
            name: User's full name
            organization: User's organization/institution
            country: User's country
            legacy: If True, uses legacy mode (password emailed directly).
                   If False (default), uses secure mode (reset link emailed
                   so user can set their own password).
            role_title: User's role or job title
            sector: User's sector
            sector_other: Free-text sector description (when other is selected)
            gender_identity: User's gender identity selection
            gender_identity_description: Free-text gender description
                (when self_describe is selected)
            gee_license_acknowledged: Whether user acknowledged GEE licensing
            purpose_of_use: User's purpose for using the tool
            purpose_of_use_other: Free-text purpose description
                (when other is selected)
        """
        payload = {
            "email": email,
            "name": name,
            "institution": organization,
            "country": country,
        }
        if role_title is not None:
            payload["role_title"] = role_title
        if sector is not None:
            payload["sector"] = sector
        if sector_other is not None:
            payload["sector_other"] = sector_other
        if gender_identity is not None:
            payload["gender_identity"] = gender_identity
        if gender_identity_description is not None:
            payload["gender_identity_description"] = gender_identity_description
        if gee_license_acknowledged is not None:
            payload["gee_license_acknowledged"] = gee_license_acknowledged
        if purpose_of_use is not None:
            payload["purpose_of_use"] = purpose_of_use
        if purpose_of_use_other is not None:
            payload["purpose_of_use_other"] = purpose_of_use_other
        endpoint = "/api/v1/user?legacy={}".format("true" if legacy else "false")
        return self.call_api(endpoint, method="post", payload=payload)

    def update_user(
        self,
        email,
        name,
        organization,
        country,
        email_notifications_enabled=True,
        role_title=None,
        sector=None,
        sector_other=None,
        purpose_of_use=None,
        purpose_of_use_other=None,
        gender_identity=None,
        gender_identity_description=None,
        gee_license_acknowledged=None,
    ):
        payload = {
            "email": email,
            "name": name,
            "institution": organization,
            "country": country,
            "email_notifications_enabled": email_notifications_enabled,
        }
        if role_title is not None:
            payload["role_title"] = role_title
        if sector is not None:
            payload["sector"] = sector
        if sector_other is not None:
            payload["sector_other"] = sector_other
        if purpose_of_use is not None:
            payload["purpose_of_use"] = purpose_of_use
        if purpose_of_use_other is not None:
            payload["purpose_of_use_other"] = purpose_of_use_other
        if gender_identity is not None:
            payload["gender_identity"] = gender_identity
        if gender_identity_description is not None:
            payload["gender_identity_description"] = gender_identity_description
        if gee_license_acknowledged is not None:
            payload["gee_license_acknowledged"] = gee_license_acknowledged

        return self.call_api("/api/v1/user/me", "patch", payload, use_token=True)

    def get_gee_credentials(self):
        """Return the GEE credentials status dict for the current user, or None."""
        resp = self.call_api("/api/v1/user/me/gee-credentials", use_token=True)
        if resp:
            return resp.get("data")
        return None

    def initiate_gee_oauth(self):
        """Initiate the GEE OAuth flow.  Returns the data dict (auth_url, state) or None."""
        resp = self.call_api(
            "/api/v1/user/me/gee-oauth/initiate", "post", use_token=True
        )
        if resp:
            return resp.get("data")
        return None

    def delete_gee_credentials(self):
        """Delete the current user's GEE credentials.  Returns True on success."""
        resp = self.call_api(
            "/api/v1/user/me/gee-credentials", "delete", use_token=True
        )
        return resp is not None

    def update_gee_project(self, project_id):
        """Set the GCP project ID for the current user's GEE OAuth credentials."""
        resp = self.call_api(
            "/api/v1/user/me/gee-credentials/project",
            "patch",
            {"cloud_project": project_id},
            use_token=True,
        )
        return resp is not None

    def test_gee_credentials(self):
        """Test the current user's GEE credentials.  Returns the response dict or None."""
        resp = self.call_api(
            "/api/v1/user/me/gee-credentials/test", "post", use_token=True
        )
        return resp

    #     payload = {
    #         "email": email,
    #         "name": name,
    #         "institution": organization,
    #         "country": country,
    #     }
    #
    #     return self.call_api(
    #         "/api/v1/user/{}".format(quote_plus(email)),
    #         "patch",
    #         payload,
    #         use_token=True,
    #     )

    def get_execution(self, date=None):
        log("Fetching executions")

        # Collect all executions across all pages
        all_executions = []
        page = 1
        per_page = 100  # Maximum allowed per page

        while True:
            query = ["include=script", f"page={page}", f"per_page={per_page}"]

            if date:
                query.append("updated_at={}".format(date))
            query = "?" + "&".join(query)

            # Always use the user-specific endpoint to ensure we only get
            # executions from the active user, regardless of admin privileges
            resp = self.call_api(
                "/api/v1/execution/user{}".format(query), method="get", use_token=True
            )

            if not resp:
                log(f"No response from API on page {page}, stopping pagination")
                break

            # Add executions from this page
            page_executions = resp.get("data", [])
            all_executions.extend(page_executions)

            # Check if we have more pages
            current_page = resp.get("page", page)
            total_items = resp.get("total", 0)
            current_per_page = resp.get("per_page", per_page)

            log(
                f"Page {current_page}: fetched {len(page_executions)} executions (total so far: {len(all_executions)}/{total_items})"
            )

            # If we got fewer items than per_page, or we've reached the end
            if (
                len(page_executions) < current_per_page
                or (current_page * current_per_page) >= total_items
            ):
                break

            page += 1

        log(f"Fetched {len(all_executions)} executions across {page} pages")
        return all_executions

    def get_script(self, id=None):
        if id:
            resp = self.call_api(
                "/api/v1/script/{}".format(quote_plus(id)), "get", use_token=True
            )
        else:
            resp = self.call_api("/api/v1/script", "get", use_token=True)

        if resp:
            return resp["data"]
        else:
            return None

    def get_boundaries_list(self, release_type="gbOpen"):
        """
        Get hierarchical list of all boundaries with download URLs.

        Args:
            release_type: geoBoundaries release type (gbOpen, gbHumanitarian, gbAuthoritative)

        Returns:
            Dictionary with boundaries list and metadata, or None on error

        Response format:
        {
            "boundaries": [
                {
                    "boundaryISO": "USA",
                    "boundaryName": "United States",
                    "releaseType": "gbOpen",
                    "adm0_geojson_url": "https://...",
                    "adm0_topojson_url": "https://...",
                    "adm1_geojson_url": "https://...",
                    "adm1_topojson_url": "https://...",
                    "admin1_units": [
                        {"shapeID": "...", "shapeName": "California"},
                        ...
                    ]
                },
                ...
            ],
            "last_updated": "2025-01-26T10:30:00.000000",
            "release_type": "gbOpen"
        }
        """
        query = f"?release_type={release_type}"
        resp = self.call_api(
            f"/api/v1/data/boundaries/list{query}", "get", use_token=True
        )

        if resp:
            return resp
        else:
            return None

    def get_boundaries_last_updated(self, release_type="gbOpen"):
        """
        Get the most recent modification timestamp across all boundaries.

        Args:
            release_type: geoBoundaries release type (gbOpen, gbHumanitarian, gbAuthoritative)

        Returns:
            Dictionary with last updated timestamp, or None on error

        Response format:
        {
            "last_updated": "2025-01-26T10:30:00.000000",
            "release_type": "gbOpen"
        }
        """
        query = f"?release_type={release_type}"
        resp = self.call_api(
            f"/api/v1/data/boundaries/last-updated{query}", "get", use_token=True
        )

        if resp:
            return resp.get("data", {})
        else:
            return None


def get_default_api_client():
    """Get a default API client with the current API URL configuration."""
    return APIClient(get_api_url(), TIMEOUT)
