from __future__ import annotations

import re
from typing import Any

from .._compat import compact_types, optional_import
from .base import ErrorInfo

SQLAlchemyError = optional_import("sqlalchemy.exc", "SQLAlchemyError")
IntegrityError = optional_import("sqlalchemy.exc", "IntegrityError")
OperationalError = optional_import("sqlalchemy.exc", "OperationalError")
ProgrammingError = optional_import("sqlalchemy.exc", "ProgrammingError")
DataError = optional_import("sqlalchemy.exc", "DataError")
DisconnectionError = optional_import("sqlalchemy.exc", "DisconnectionError")
PendingRollbackError = optional_import("sqlalchemy.exc", "PendingRollbackError")
ResourceClosedError = optional_import("sqlalchemy.exc", "ResourceClosedError")
SQLAlchemyTimeoutError = optional_import("sqlalchemy.exc", "TimeoutError")
NoSuchTableError = optional_import("sqlalchemy.exc", "NoSuchTableError")
PsycopgError = optional_import("psycopg2", "Error")
PsycopgIntegrityError = optional_import("psycopg2", "IntegrityError")
PsycopgOperationalError = optional_import("psycopg2", "OperationalError")
PsycopgProgrammingError = optional_import("psycopg2", "ProgrammingError")
PsycopgDataError = optional_import("psycopg2", "DataError")
PsycopgErrors = optional_import("psycopg2", "errors")
Psycopg3Error = optional_import("psycopg", "Error")
Psycopg3IntegrityError = optional_import("psycopg", "IntegrityError")
Psycopg3OperationalError = optional_import("psycopg", "OperationalError")
Psycopg3ProgrammingError = optional_import("psycopg", "ProgrammingError")
Psycopg3DataError = optional_import("psycopg", "DataError")
Psycopg3Errors = optional_import("psycopg", "errors")
AsyncPGExceptions = optional_import("asyncpg", "exceptions")
MySQLConnectorError = optional_import("mysql.connector.errors", "Error")
MySQLConnectorIntegrityError = optional_import("mysql.connector.errors", "IntegrityError")
MySQLConnectorOperationalError = optional_import("mysql.connector.errors", "OperationalError")
MySQLConnectorProgrammingError = optional_import("mysql.connector.errors", "ProgrammingError")
MySQLConnectorDataError = optional_import("mysql.connector.errors", "DataError")
PyMySQLError = optional_import("pymysql.err", "MySQLError")
PyMySQLIntegrityError = optional_import("pymysql.err", "IntegrityError")
PyMySQLOperationalError = optional_import("pymysql.err", "OperationalError")
PyMySQLProgrammingError = optional_import("pymysql.err", "ProgrammingError")
PyMySQLDataError = optional_import("pymysql.err", "DataError")
MySQLdbError = optional_import("MySQLdb", "Error")
MySQLdbIntegrityError = optional_import("MySQLdb", "IntegrityError")
MySQLdbOperationalError = optional_import("MySQLdb", "OperationalError")
MySQLdbProgrammingError = optional_import("MySQLdb", "ProgrammingError")
MySQLdbDataError = optional_import("MySQLdb", "DataError")
SQLiteError = optional_import("sqlite3", "Error")
SQLiteIntegrityError = optional_import("sqlite3", "IntegrityError")
SQLiteOperationalError = optional_import("sqlite3", "OperationalError")
SQLiteProgrammingError = optional_import("sqlite3", "ProgrammingError")
SQLiteDataError = optional_import("sqlite3", "DataError")
PyMongoErrors = optional_import("pymongo", "errors")

AsyncPGDataError = getattr(AsyncPGExceptions, "DataError", None) if AsyncPGExceptions else None
AsyncPGIntegrityError = (
    getattr(AsyncPGExceptions, "IntegrityConstraintViolationError", None) if AsyncPGExceptions else None
)
AsyncPGConnectionError = getattr(AsyncPGExceptions, "PostgresConnectionError", None) if AsyncPGExceptions else None
AsyncPGSyntaxOrAccessError = getattr(AsyncPGExceptions, "SyntaxOrAccessError", None) if AsyncPGExceptions else None
AsyncPGPostgresError = getattr(AsyncPGExceptions, "PostgresError", None) if AsyncPGExceptions else None
AsyncPGInterfaceError = getattr(AsyncPGExceptions, "InterfaceError", None) if AsyncPGExceptions else None
NotNullViolation = getattr(PsycopgErrors, "NotNullViolation", None) if PsycopgErrors else None
UniqueViolation = getattr(PsycopgErrors, "UniqueViolation", None) if PsycopgErrors else None
ForeignKeyViolation = getattr(PsycopgErrors, "ForeignKeyViolation", None) if PsycopgErrors else None
CheckViolation = getattr(PsycopgErrors, "CheckViolation", None) if PsycopgErrors else None
UndefinedTable = getattr(PsycopgErrors, "UndefinedTable", None) if PsycopgErrors else None
NotNullViolation3 = getattr(Psycopg3Errors, "NotNullViolation", None) if Psycopg3Errors else None
UniqueViolation3 = getattr(Psycopg3Errors, "UniqueViolation", None) if Psycopg3Errors else None
ForeignKeyViolation3 = getattr(Psycopg3Errors, "ForeignKeyViolation", None) if Psycopg3Errors else None
CheckViolation3 = getattr(Psycopg3Errors, "CheckViolation", None) if Psycopg3Errors else None
UndefinedTable3 = getattr(Psycopg3Errors, "UndefinedTable", None) if Psycopg3Errors else None

DuplicateKeyError = getattr(PyMongoErrors, "DuplicateKeyError", None) if PyMongoErrors else None
BulkWriteError = getattr(PyMongoErrors, "BulkWriteError", None) if PyMongoErrors else None
ClientBulkWriteException = getattr(PyMongoErrors, "ClientBulkWriteException", None) if PyMongoErrors else None
WriteError = getattr(PyMongoErrors, "WriteError", None) if PyMongoErrors else None
OperationFailure = getattr(PyMongoErrors, "OperationFailure", None) if PyMongoErrors else None
DocumentTooLarge = getattr(PyMongoErrors, "DocumentTooLarge", None) if PyMongoErrors else None
ConnectionFailure = getattr(PyMongoErrors, "ConnectionFailure", None) if PyMongoErrors else None
AutoReconnect = getattr(PyMongoErrors, "AutoReconnect", None) if PyMongoErrors else None
ServerSelectionTimeoutError = getattr(PyMongoErrors, "ServerSelectionTimeoutError", None) if PyMongoErrors else None
WaitQueueTimeoutError = getattr(PyMongoErrors, "WaitQueueTimeoutError", None) if PyMongoErrors else None
NetworkTimeout = getattr(PyMongoErrors, "NetworkTimeout", None) if PyMongoErrors else None
ExecutionTimeout = getattr(PyMongoErrors, "ExecutionTimeout", None) if PyMongoErrors else None
WTimeoutError = getattr(PyMongoErrors, "WTimeoutError", None) if PyMongoErrors else None
PyMongoError = getattr(PyMongoErrors, "PyMongoError", None) if PyMongoErrors else None
ConfigurationError = getattr(PyMongoErrors, "ConfigurationError", None) if PyMongoErrors else None
InvalidOperation = getattr(PyMongoErrors, "InvalidOperation", None) if PyMongoErrors else None
InvalidURI = getattr(PyMongoErrors, "InvalidURI", None) if PyMongoErrors else None

INTEGRITY_EXCEPTION_TYPES = compact_types(
    IntegrityError,
    UniqueViolation,
    ForeignKeyViolation,
    NotNullViolation,
    CheckViolation,
    PsycopgIntegrityError,
    UniqueViolation3,
    ForeignKeyViolation3,
    NotNullViolation3,
    CheckViolation3,
    Psycopg3IntegrityError,
    AsyncPGIntegrityError,
    MySQLConnectorIntegrityError,
    PyMySQLIntegrityError,
    MySQLdbIntegrityError,
    SQLiteIntegrityError,
    DuplicateKeyError,
)
DATA_EXCEPTION_TYPES = compact_types(
    DataError,
    AsyncPGDataError,
    PsycopgDataError,
    Psycopg3DataError,
    MySQLConnectorDataError,
    PyMySQLDataError,
    MySQLdbDataError,
    SQLiteDataError,
    DocumentTooLarge,
    WriteError,
)
DB_TIMEOUT_EXCEPTION_TYPES = compact_types(
    SQLAlchemyTimeoutError,
    NetworkTimeout,
    ExecutionTimeout,
    WTimeoutError,
)
DB_UNAVAILABLE_EXCEPTION_TYPES = compact_types(
    OperationalError,
    DisconnectionError,
    PsycopgOperationalError,
    Psycopg3OperationalError,
    AsyncPGConnectionError,
    MySQLConnectorOperationalError,
    PyMySQLOperationalError,
    MySQLdbOperationalError,
    SQLiteOperationalError,
    ConnectionFailure,
    AutoReconnect,
    ServerSelectionTimeoutError,
    WaitQueueTimeoutError,
)
PROGRAMMING_EXCEPTION_TYPES = compact_types(
    ProgrammingError,
    UndefinedTable,
    PsycopgProgrammingError,
    UndefinedTable3,
    Psycopg3ProgrammingError,
    AsyncPGSyntaxOrAccessError,
    AsyncPGInterfaceError,
    MySQLConnectorProgrammingError,
    PyMySQLProgrammingError,
    MySQLdbProgrammingError,
    SQLiteProgrammingError,
    PendingRollbackError,
    ResourceClosedError,
    NoSuchTableError,
    ConfigurationError,
    InvalidOperation,
    InvalidURI,
)
GENERIC_DATABASE_EXCEPTION_TYPES = compact_types(
    SQLAlchemyError,
    PsycopgError,
    Psycopg3Error,
    AsyncPGPostgresError,
    MySQLConnectorError,
    PyMySQLError,
    MySQLdbError,
    SQLiteError,
    PyMongoError,
    OperationFailure,
    BulkWriteError,
    ClientBulkWriteException,
)


def _normalize_identifier(identifier: str) -> str:
    clean = identifier.strip().strip('"').strip("'").strip("`")
    return clean.split(".")[-1]


def _extract_sql_text(exc: Exception, message: str) -> str:
    sql_text = getattr(exc, "statement", "") or ""
    if sql_text:
        return str(sql_text)

    match = re.search(r"\[sql:\s*(.*?)\]", message, flags=re.IGNORECASE | re.DOTALL)
    return match.group(1).strip() if match else ""


def _extract_insert_column_name(sql_text: str, argument_number: str) -> str | None:
    if not sql_text or "insert into" not in sql_text.lower():
        return None
    columns_match = re.search(r"\(([^)]+)\)\s*values", sql_text, flags=re.IGNORECASE)
    if not columns_match:
        return None
    columns = [column.strip().strip('"').strip("`") for column in columns_match.group(1).split(",")]
    index = int(argument_number) - 1
    return columns[index] if 0 <= index < len(columns) else None


def _extract_column_reference(message: str) -> str | None:
    patterns = [
        r"column\s+['\"`](?P<field>[^'\"`]+)['\"`]",
        r"for column\s+['\"`](?P<field>[^'\"`]+)['\"`]",
    ]
    for pattern in patterns:
        match = re.search(pattern, message, flags=re.IGNORECASE)
        if match:
            return _normalize_identifier(match.group("field"))
    return None


def _extract_duplicate_key_name(message: str) -> str | None:
    match = re.search(r"for key\s+['\"`](?P<key>[^'\"`]+)['\"`]", message, flags=re.IGNORECASE)
    return _normalize_identifier(match.group("key")) if match else None


def _extract_duplicate_value(message: str) -> str | None:
    postgres_match = re.search(r"key\s*\((?P<field>[^)]+)\)=\((?P<value>[^)]*)\)", message, flags=re.IGNORECASE)
    if postgres_match:
        return postgres_match.group("value").strip()
    mysql_match = re.search(r"duplicate entry\s+['\"`](?P<value>[^'\"`]+)['\"`]", message, flags=re.IGNORECASE)
    if mysql_match:
        return mysql_match.group("value").strip()
    return None


def _extract_duplicate_context(message: str, exc: Exception) -> dict[str, Any]:
    context: dict[str, Any] = {}

    details = getattr(exc, "details", None)
    if isinstance(details, dict):
        key_value = details.get("keyValue")
        if isinstance(key_value, dict):
            for key, value in key_value.items():
                context["field"] = str(key)
                context["value"] = value
                break

    duplicate_match = re.search(r"key\s*\((?P<field>[^)]+)\)=", message, flags=re.IGNORECASE)
    if duplicate_match and "field" not in context:
        context["field"] = _normalize_identifier(duplicate_match.group("field"))

    duplicate_key_name = _extract_duplicate_key_name(message)
    if duplicate_key_name:
        context.setdefault("field", duplicate_key_name)
        context["key"] = duplicate_key_name

    duplicate_value = _extract_duplicate_value(message)
    if duplicate_value and "value" not in context:
        context["value"] = duplicate_value

    constraint_match = re.search(r"constraint\s+['\"`](?P<constraint>[^'\"`]+)['\"`]", message, flags=re.IGNORECASE)
    if constraint_match:
        context["constraint"] = _normalize_identifier(constraint_match.group("constraint"))

    return context


def _describe_argument_type_error(message: str, sql_text: str) -> str | None:
    lower_message = message.lower()
    argument_match = re.search(
        r"argument\s*\$(\d+)[^:]*:\s*[^()]*\((?:expected|expecting)\s*([\w\s]+),\s*got\s*([\w\s]+)\)",
        lower_message,
    )
    if not argument_match:
        argument_match = re.search(
            r"invalid input for query argument\s*\$(\d+).*expected\s*(\w+),\s*got\s*(\w+)",
            lower_message,
        )
    if not argument_match:
        return None
    argument_number, expected, got = [part.strip() for part in argument_match.groups()]
    column_name = _extract_insert_column_name(sql_text, argument_number)
    if column_name:
        return f"Invalid data type for column '{column_name}': expected {expected}, got {got}."
    return f"Invalid data type for database parameter ${argument_number}: expected {expected}, got {got}."


def _describe_enum_error(message: str) -> str | None:
    match = re.search(r'invalid input value for enum\s+(\w+):\s*"([^"]+)"', message.lower())
    if not match:
        return None
    enum_name, invalid_value = match.groups()
    return (
        f"Invalid value '{invalid_value}' for enum column '{enum_name}'. "
        f"Please use one of the allowed values defined for '{enum_name}'."
    )


def _extract_operator_type_mismatch(message: str) -> dict[str, str] | None:
    match = re.search(
        r"operator does not exist:\s*(?P<left>.+?)\s*(?P<operator>=|!=|<>|<=|>=|<|>)\s*(?P<right>.+?)(?:\n|$)",
        message,
        flags=re.IGNORECASE,
    )
    if not match:
        return None
    details = {
        "left_type": match.group("left").strip(),
        "operator": match.group("operator").strip(),
        "right_type": match.group("right").strip(),
    }
    hint_match = re.search(r"HINT:\s*(?P<hint>.*)", message, flags=re.IGNORECASE | re.DOTALL)
    if hint_match:
        details["hint"] = hint_match.group("hint").strip()
    return details


def _is_duplicate_message(lower_message: str) -> bool:
    return any(
        marker in lower_message
        for marker in ("duplicate key", "duplicate entry", "unique constraint", "violates unique", "e11000")
    )


def _message_contains_any(lower_message: str, markers: tuple[str, ...]) -> bool:
    return any(marker in lower_message for marker in markers)


def _database_message(exc: Exception) -> str:
    original = getattr(exc, "orig", None)
    message = str(original or exc)
    lower_message = message.lower()
    sql_text = _extract_sql_text(exc, message)

    pymongo_details = getattr(exc, "details", None)
    if isinstance(pymongo_details, dict):
        write_errors = pymongo_details.get("writeErrors")
        if isinstance(write_errors, list) and write_errors:
            first = write_errors[0]
            if isinstance(first, dict):
                errmsg = str(first.get("errmsg") or "")
                if errmsg:
                    lower_message = errmsg.lower()
                    message = errmsg

    enum_message = _describe_enum_error(message)
    if enum_message:
        return enum_message

    argument_message = _describe_argument_type_error(message, sql_text)
    if argument_message:
        return argument_message

    operator_type_mismatch = _extract_operator_type_mismatch(message)
    if operator_type_mismatch:
        left_type = operator_type_mismatch["left_type"].lower()
        right_type = operator_type_mismatch["right_type"].lower()
        if "timestamp" in left_type and "character varying" in right_type:
            return "A date/time filter has an invalid value type."
        if "date" in left_type and "character varying" in right_type:
            return "A date filter has an invalid value type."
        return "The query compared incompatible value types."

    foreign_key_match = re.search(r"key\s*\((?P<field>[^)]+)\)=", message, flags=re.IGNORECASE)
    column_name = _extract_column_reference(message)
    duplicate_context = _extract_duplicate_context(message, exc)

    if _is_duplicate_message(lower_message) or isinstance(exc, (DuplicateKeyError,) if DuplicateKeyError else tuple()):
        field_name = duplicate_context.get("field")
        key_name = duplicate_context.get("key")
        constraint_name = duplicate_context.get("constraint")
        if field_name and not str(field_name).startswith(("uq_", "uk_", "uniq_", "unique_", "idx_")):
            return f"A record with this {field_name} already exists."
        if key_name:
            return f"Duplicate entry violates {key_name}."
        if constraint_name:
            return f"Duplicate entry violates {constraint_name}."
        return "A record with the same value already exists."

    if _message_contains_any(
        lower_message,
        ("foreign key", "cannot add or update a child row", "cannot delete or update a parent row"),
    ):
        if foreign_key_match:
            return f"The related value for {_normalize_identifier(foreign_key_match.group('field'))} does not exist."
        return "A related record was not found."

    if _message_contains_any(lower_message, ("not-null", "cannot be null")):
        if column_name:
            return f"The field {column_name} is required."
        return "A required field is missing."

    if "check constraint" in lower_message:
        return "One or more fields failed a database validation rule."

    if _message_contains_any(lower_message, ("data too long for column", "stringdatarighttruncation")):
        if column_name:
            return f"The value for field {column_name} is too long."
        return "One or more values are longer than the allowed database limit."

    if _message_contains_any(lower_message, ("out of range value for column", "numericvalueoutofrange")):
        if column_name:
            return f"The numeric value for field {column_name} is outside the allowed range."
        return "A numeric value is outside the allowed range."

    if _message_contains_any(lower_message, ("deadlock", "serialization failure", "lock not available")):
        return "The database could not complete the operation because of a temporary concurrency conflict."

    if isinstance(exc, DB_TIMEOUT_EXCEPTION_TYPES) or _message_contains_any(
        lower_message,
        ("timeout", "lock wait timeout", "statement timeout", "network timeout", "execution timeout"),
    ):
        return "Database request timed out."

    if isinstance(exc, DB_UNAVAILABLE_EXCEPTION_TYPES) or _message_contains_any(
        lower_message,
        (
            "could not connect",
            "can't connect",
            "unable to connect",
            "lost connection",
            "server has gone away",
            "connection refused",
            "server selection timed out",
            "waiting for a free slot in the connection pool",
        ),
    ):
        return "Unable to connect to the database server."

    if isinstance(exc, PROGRAMMING_EXCEPTION_TYPES):
        if "syntax" in lower_message or "you have an error in your sql syntax" in lower_message:
            return "The database query is invalid."
        if "undefined column" in lower_message or "unknown column" in lower_message:
            return "The query referenced a column that does not exist."
        if "undefined table" in lower_message or ("table" in lower_message and "doesn't exist" in lower_message):
            return "The query referenced a table that does not exist."
        return "The database query is invalid."

    if "expected str" in lower_message and "got int" in lower_message:
        return "A numeric value was provided where a text value was expected."
    if "expected int" in lower_message and "got str" in lower_message:
        return "A text value was provided where a number was expected."
    if _message_contains_any(
        lower_message,
        (
            "invalid input syntax",
            "incorrect integer value",
            "truncated incorrect integer value",
            "incorrect datetime value",
            "invalid datetime format",
            "data truncated",
            "invalidtextrepresentation",
            "document too large",
        ),
    ):
        return "One or more fields have the wrong type or format."
    if "out of range" in lower_message:
        return "A numeric value is outside the allowed range."
    return "A database error occurred while processing the request."


def _database_details(exc: Exception) -> list[dict[str, Any]] | None:
    message = str(getattr(exc, "orig", None) or exc)
    lower_message = message.lower()

    pymongo_details = getattr(exc, "details", None)
    if isinstance(pymongo_details, dict):
        key_value = pymongo_details.get("keyValue")
        if isinstance(key_value, dict) and key_value:
            field, value = next(iter(key_value.items()))
            return [
                {
                    "type": "duplicate_resource",
                    "message": _database_message(exc),
                    "field": str(field),
                    "value": value,
                }
            ]

        write_errors = pymongo_details.get("writeErrors")
        if isinstance(write_errors, list) and write_errors:
            first = write_errors[0]
            if isinstance(first, dict):
                detail = {
                    "type": "invalid_data",
                    "message": _database_message(exc),
                }
                if "code" in first:
                    detail["code"] = first["code"]
                if "errmsg" in first:
                    detail["driver_message"] = first["errmsg"]
                if "index" in first:
                    detail["index"] = first["index"]
                return [detail]

    operator_type_mismatch = _extract_operator_type_mismatch(message)
    if operator_type_mismatch:
        detail: dict[str, Any] = {"type": "invalid_operator_types", "message": _database_message(exc)}
        detail.update(operator_type_mismatch)
        return [detail]

    if _is_duplicate_message(lower_message) or isinstance(exc, (DuplicateKeyError,) if DuplicateKeyError else tuple()):
        duplicate_context = _extract_duplicate_context(message, exc)
        if not duplicate_context:
            return None
        detail = {"type": "duplicate_resource", "message": _database_message(exc)}
        detail.update(duplicate_context)
        return [detail]

    if _message_contains_any(
        lower_message,
        ("foreign key", "cannot add or update a child row", "cannot delete or update a parent row"),
    ):
        fk_match = re.search(r"key\s*\((?P<field>[^)]+)\)=", message, flags=re.IGNORECASE)
        detail = {"type": "invalid_reference", "message": _database_message(exc)}
        if fk_match:
            detail["field"] = _normalize_identifier(fk_match.group("field"))
        constraint_match = re.search(r"constraint\s+['\"`](?P<constraint>[^'\"`]+)['\"`]", message, flags=re.IGNORECASE)
        if constraint_match:
            detail["constraint"] = _normalize_identifier(constraint_match.group("constraint"))
        return [detail]

    if _message_contains_any(lower_message, ("not-null", "cannot be null")):
        column_name = _extract_column_reference(message)
        detail = {"type": "missing_required_field", "message": _database_message(exc)}
        if column_name:
            detail["field"] = column_name
        return [detail]

    if "check constraint" in lower_message:
        constraint_match = re.search(r"constraint\s+['\"`](?P<constraint>[^'\"`]+)['\"`]", message, flags=re.IGNORECASE)
        detail = {"type": "constraint_violation", "message": _database_message(exc)}
        if constraint_match:
            detail["constraint"] = _normalize_identifier(constraint_match.group("constraint"))
        return [detail]

    if _message_contains_any(lower_message, ("deadlock", "serialization failure", "lock not available")):
        return [{"type": "database_retryable_conflict", "message": _database_message(exc), "retryable": True}]

    return None


def _database_status(exc: Exception) -> tuple[int, str]:
    raw_message = str(getattr(exc, "orig", None) or exc).lower()

    if "operator does not exist:" in raw_message:
        return 422, "invalid_data"
    if _is_duplicate_message(raw_message) or isinstance(exc, (DuplicateKeyError,) if DuplicateKeyError else tuple()):
        return 409, "duplicate_resource"
    if _message_contains_any(raw_message, ("foreign key", "cannot add or update a child row", "cannot delete or update a parent row")):
        return 422, "invalid_reference"
    if _message_contains_any(raw_message, ("not-null", "cannot be null")):
        return 422, "missing_required_field"
    if "check constraint" in raw_message:
        return 422, "constraint_violation"
    if _message_contains_any(raw_message, ("deadlock", "serialization failure", "lock not available")):
        return 409, "database_retryable_conflict"
    if isinstance(exc, DB_TIMEOUT_EXCEPTION_TYPES) or _message_contains_any(
        raw_message,
        ("timeout", "lock wait timeout", "statement timeout", "network timeout", "execution timeout"),
    ):
        return 504, "database_timeout"
    if isinstance(exc, DATA_EXCEPTION_TYPES) or _message_contains_any(
        raw_message,
        (
            "data too long",
            "invalid input syntax",
            "incorrect integer value",
            "truncated incorrect integer value",
            "incorrect datetime value",
            "invalid datetime format",
            "data truncated",
            "out of range",
            "document too large",
        ),
    ):
        return 422, "invalid_data"
    if isinstance(exc, DB_UNAVAILABLE_EXCEPTION_TYPES) or _message_contains_any(
        raw_message,
        (
            "could not connect",
            "can't connect",
            "unable to connect",
            "lost connection",
            "server has gone away",
            "connection refused",
            "server selection timed out",
        ),
    ):
        return 503, "database_unavailable"
    if isinstance(exc, PROGRAMMING_EXCEPTION_TYPES) or _message_contains_any(
        raw_message,
        ("unknown column", "undefined column", "undefined table", "sql syntax", "doesn't exist"),
    ):
        return 500, "database_programming_error"
    return 500, "database_error"


def _db_retryable(code: str) -> bool | None:
    if code in ("database_timeout", "database_retryable_conflict"):
        return True
    if code in ("duplicate_resource", "invalid_reference", "missing_required_field", "constraint_violation", "invalid_data", "database_programming_error"):
        return False
    return None


def describe_database_exception(exc: Exception) -> ErrorInfo | None:
    if INTEGRITY_EXCEPTION_TYPES and isinstance(exc, INTEGRITY_EXCEPTION_TYPES):
        status_code, code = _database_status(exc)
        return ErrorInfo(status_code, code, _database_message(exc), _database_details(exc), exc.__class__.__name__, retryable=_db_retryable(code))

    if DATA_EXCEPTION_TYPES and isinstance(exc, DATA_EXCEPTION_TYPES):
        status_code, code = _database_status(exc)
        return ErrorInfo(status_code, code, _database_message(exc), _database_details(exc), exc.__class__.__name__, retryable=_db_retryable(code))

    if DB_TIMEOUT_EXCEPTION_TYPES and isinstance(exc, DB_TIMEOUT_EXCEPTION_TYPES):
        status_code, code = _database_status(exc)
        return ErrorInfo(status_code, code, _database_message(exc), _database_details(exc), exc.__class__.__name__, retryable=True)

    if DB_UNAVAILABLE_EXCEPTION_TYPES and isinstance(exc, DB_UNAVAILABLE_EXCEPTION_TYPES):
        # Must call _database_status() here — a timeout-phrased OperationalError (e.g. "lock wait
        # timeout exceeded") must resolve to 504/database_timeout, not the hardcoded 503 below.
        status_code, code = _database_status(exc)
        return ErrorInfo(status_code, code, _database_message(exc), _database_details(exc), exc.__class__.__name__, retryable=_db_retryable(code))

    if PROGRAMMING_EXCEPTION_TYPES and isinstance(exc, PROGRAMMING_EXCEPTION_TYPES):
        status_code, code = _database_status(exc)
        return ErrorInfo(status_code, code, _database_message(exc), _database_details(exc), exc.__class__.__name__, retryable=_db_retryable(code))

    if GENERIC_DATABASE_EXCEPTION_TYPES and isinstance(exc, GENERIC_DATABASE_EXCEPTION_TYPES):
        status_code, code = _database_status(exc)
        return ErrorInfo(status_code, code, _database_message(exc), _database_details(exc), exc.__class__.__name__, retryable=_db_retryable(code))

    return None
