import json
from typing import Optional, List, Dict
from ._state_machine import LogLine


class OuterboundsBackendUnhealthyException(Exception):
    """This exception is raised when the Outerbounds platform is unhealthy (5xx errors) or unreachable."""

    def __init__(
        self,
        url: str,
        method: str,
        status_code: Optional[int] = None,
        text: Optional[str] = None,
        message: Optional[str] = None,
    ):
        self.url = url
        self.method = method
        self.status_code = status_code
        self.text = text
        self.message = message
        super().__init__(self.message)


class OuterboundsForbiddenException(Exception):
    """This exception is raised when access to an Outerbounds API is forbidden (HTTP 403)."""

    def __init__(
        self,
        url: str,
        method: str,
        text: str,
    ):
        self.url = url
        self.method = method
        self.text = text
        self.message = (
            f"Access forbidden (HTTP 403) when calling {url}. "
            "This typically means your credentials lack permission for this operation. "
            "Please verify that you outerbounds / metaflow configuration has access to the "
            "correct perimeter.\n"
            "If you believe this is an error, contact your Outerbounds administrator."
        )
        super().__init__(self.message)


class OuterboundsConfigurationException(Exception):
    """This exception is raised when Outerbounds configuration is missing or invalid."""

    def __init__(self, missing_config: str):
        self.missing_config = missing_config
        self.message = (
            f"Outerbounds configuration '{missing_config}' not found.\n\n"
            "If running locally:\n"
            "  - Run: outerbounds configure <magic-string-from-outerbounds-ui>\n"
            "If running remotely (e.g., in a Metaflow task):\n"
            "  - Ensure you have the Outerbounds distribution installed (pip install outerbounds)\n"
            "    and not the open-source Metaflow. The Outerbounds fork injects configuration\n"
            "    into remote tasks automatically which may not be present in open source metaflow.\n"
        )
        super().__init__(self.message)


class CapsuleApiException(Exception):
    def __init__(
        self,
        url: str,
        method: str,
        status_code: int,
        text: str,
        message: Optional[str] = None,
    ):
        self.url = url
        self.method = method
        self.status_code = status_code
        self.text = text
        self.message = message

    def __str__(self):
        return (
            f"CapsuleApiException: {self.url} [{self.method}]: Status Code: {self.status_code} \n\n {self.text}"
            + (f"\n\n {self.message}" if self.message else "")
        )


class CapsuleDeploymentException(Exception):
    """Base exception for all capsule deployment failures."""

    def __init__(
        self,
        capsule_id: str,
        message: str,
    ):
        self.capsule_id = capsule_id
        self.message = message
        super().__init__(self.message)

    def __str__(self):
        return f"CapsuleDeploymentException: [{self.capsule_id}] :: {self.message}"


class CapsuleCrashLoopException(CapsuleDeploymentException):
    """Raised when a worker enters CrashLoopBackOff or Failed state."""

    def __init__(
        self,
        capsule_id: str,
        worker_id: str,
        logs: Optional[List[LogLine]] = None,
    ):
        self.worker_id = worker_id
        self.logs = logs or []
        message = f"Worker ID ({worker_id}) is crashlooping. Please check the logs for more information."
        super().__init__(capsule_id, message)

    def __str__(self):
        return f"CapsuleCrashLoopException: [{self.capsule_id}] :: {self.message}"


class CapsuleReadinessException(CapsuleDeploymentException):
    """Raised when capsule fails to meet readiness conditions within timeout."""

    def __init__(
        self,
        capsule_id: str,
        reason: Optional[str] = None,
    ):
        message = f"Capsule {capsule_id} failed to be ready to serve traffic"
        if reason:
            message += f": {reason}"
        super().__init__(capsule_id, message)

    def __str__(self):
        return f"CapsuleReadinessException: [{self.capsule_id}] :: {self.message}"


class CapsuleConcurrentUpgradeException(CapsuleDeploymentException):
    """Raised when a concurrent upgrade invalidates the current deployment."""

    def __init__(
        self,
        capsule_id: str,
        expected_version: str,
        actual_version: str,
        modified_by: Optional[str] = None,
        modified_at: Optional[str] = None,
    ):
        self.expected_version = expected_version
        self.actual_version = actual_version
        self.modified_by = modified_by
        self.modified_at = modified_at
        message = (
            f"A capsule upgrade was triggered outside current deployment instance. "
            f"Expected version: {expected_version}, actual version: {actual_version}"
        )
        if modified_by:
            message += f". Modified by: {modified_by}"
        if modified_at:
            message += f" at {modified_at}"
        super().__init__(capsule_id, message)

    def __str__(self):
        return (
            f"CapsuleConcurrentUpgradeException: [{self.capsule_id}] :: {self.message}"
        )


class CapsuleDeletedDuringDeploymentException(CapsuleDeploymentException):
    """Raised when a capsule is deleted while deployment is in progress."""

    def __init__(self, capsule_id: str):
        super().__init__(capsule_id, "Capsule was deleted during deployment")


class CodePackagingException(Exception):
    """Exception raised when code packaging fails."""

    pass


class AppNotFoundException(Exception):
    pass


class AppCreationFailedException(Exception):
    """Raised when app deployment submission fails due to an API error."""

    def __init__(
        self,
        app_name: str,
        status_code: int,
        error_text: str,
    ):
        self.status_code = status_code
        self.error_text = error_text
        message = f"Failed to submit app deployment: HTTP {status_code} - {error_text}"
        if status_code == 400:
            # 400 = validation error; the app configuration is invalid and must be fixed.
            message = "Invalid app deployment configuration submitted: "
            try:
                reason_for_failure = json.loads(error_text).get("message", error_text)
            except json.JSONDecodeError:
                reason_for_failure = error_text
            message += reason_for_failure
            message += (
                "\n\nCheck your config file or CLI parameters if deploying via CLI, "
                "or AppDeployer parameters if deploying programmatically."
            )

        self.message = message
        super().__init__(message)

    def __str__(self):
        return f"AppCreationFailedException: {self.message}"


class AppDeploymentException(Exception):
    """Base exception for all individual app deployment failures."""

    def __init__(self, app_id: str, message: str):
        self.app_id = app_id
        self.message = message
        self._deployed_app = None
        super().__init__(self.message)

    def __str__(self):
        return f"AppDeploymentException: [{self.app_id}] :: {self.message}"

    @property
    def deployed_app(self):
        from .deployer import DeployedApp

        return DeployedApp._from_capsule_id(self.app_id)


class AppCrashLoopException(AppDeploymentException):
    """Raised when an app worker enters CrashLoopBackOff or Failed state."""

    def __init__(
        self,
        app_id: str,
        worker_id: str,
        logs: Optional[List] = None,
    ):
        self.worker_id = worker_id
        self.logs = logs or []
        message = f"Worker ({worker_id}) is crashlooping. Please check the logs for more information."
        super().__init__(app_id, message)

    def __str__(self):
        return f"AppCrashLoopException: [{self.app_id}] :: {self.message}"


class AppReadinessException(AppDeploymentException):
    """Raised when app fails to meet readiness conditions within timeout."""

    def __init__(self, app_id: str, reason: Optional[str] = None):
        message = f"App {app_id} failed to be ready to serve traffic"
        if reason:
            message += f": {reason}"
        super().__init__(app_id, message)

    def __str__(self):
        return f"AppReadinessException: [{self.app_id}] :: {self.message}"


class AppUpgradeInProgressException(AppDeploymentException):
    """Raised when attempting to deploy while another upgrade is already in progress."""

    def __init__(
        self,
        app_id: str,
        upgrader: Optional[str] = None,
    ):
        self.upgrader = upgrader
        if upgrader:
            message = (
                f"App {app_id} is currently being upgraded by {upgrader}. "
                "Use force_upgrade=True in AppDeployer to override."
            )
        else:
            message = (
                f"App {app_id} is currently being upgraded. "
                "Use force_upgrade=True in AppDeployer to override."
            )
        super().__init__(app_id, message)

    def __str__(self):
        return f"AppUpgradeInProgressException: [{self.app_id}] :: {self.message}"


class AppConcurrentUpgradeException(AppDeploymentException):
    """Raised when a concurrent upgrade invalidates the current deployment mid-flight."""

    def __init__(
        self,
        app_id: str,
        expected_version: str,
        actual_version: str,
        modified_by: Optional[str] = None,
        modified_at: Optional[str] = None,
    ):
        self.expected_version = expected_version
        self.actual_version = actual_version
        self.modified_by = modified_by
        self.modified_at = modified_at

        modifier_info = ""
        if modified_by:
            modifier_info = f" by '{modified_by}'"
            if modified_at:
                modifier_info += f" at {modified_at}"

        message = (
            f"Another deployment was triggered{modifier_info} while this deployment was in progress.\n\n"
            f"This deployment expected to be working with version '{expected_version}', but the app "
            f"is now at version '{actual_version}'. The current deployment has been invalidated.\n\n"
            "To avoid this in the future, you can either use a unique `name` for each deployment "
            "or coordinate deployments to ensure concurrent upgrades to the same app don't overlap."
        )
        super().__init__(app_id, message)

    def __str__(self):
        return f"AppConcurrentUpgradeException: [{self.app_id}] :: {self.message}"


class AppDeletedDuringDeploymentException(AppDeploymentException):
    """Raised when an app is deleted while deployment is in progress."""

    def __init__(self, app_id: str):
        message = (
            f"App '{app_id}' was deleted while this deployment was in progress.\n\n"
            "This can happen when another process or user deletes the app during deployment. "
            "Since apps can be programmatically created and deleted, concurrent operations "
            "may conflict. If you did not intend to delete this app, check for other processes "
            "or users that may be managing deployments in this perimeter."
        )
        super().__init__(app_id, message)

    def __str__(self):
        return f"AppDeletedDuringDeploymentException: [{self.app_id}] :: {self.message}"
