# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2015-2018 CERN.
# Copyright (C) 2024-2026 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.

"""Shared database object for Invenio."""

from datetime import datetime, timezone

from flask_sqlalchemy import SQLAlchemy as FlaskSQLAlchemy
from sqlalchemy import Column, MetaData, event, util
from sqlalchemy.engine import Engine
from sqlalchemy.sql import text
from sqlalchemy.types import DateTime, TypeDecorator
from werkzeug.local import LocalProxy

NAMING_CONVENTION = util.immutabledict(
    {
        "ix": "ix_%(column_0_label)s",
        "uq": "uq_%(table_name)s_%(column_0_name)s",
        "ck": "ck_%(table_name)s_%(constraint_name)s",
        "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
        "pk": "pk_%(table_name)s",
    }
)
"""Configuration for constraint naming conventions."""

metadata = MetaData(naming_convention=NAMING_CONVENTION)
"""Default database metadata object holding associated schema constructs."""


class UTCDateTime(TypeDecorator):
    """Custom UTC datetime type."""

    impl = DateTime(timezone=True)

    # todo: should be discussed, but has to be set explicitly to remove warning
    cache_ok = False

    def process_bind_param(self, value, dialect):
        """Process value storing into database."""
        if value is None:
            return value

        if isinstance(value, str):
            if " " in value:
                value = value.replace(" ", "T")
            value = datetime.strptime(value[0:19], "%Y-%m-%dT%H:%M:%S")

        if not isinstance(value, datetime):
            msg = f"ERROR: value: {value} is not of type datetime, instead of type: {type(value)}"
            raise ValueError(msg)

        if value.tzinfo not in (None, timezone.utc):
            msg = f"Error: value: {value}, tzinfo: {value.tzinfo} doesn't have a tzinfo of None or timezone.utc."
            raise ValueError(msg)

        return value.replace(tzinfo=timezone.utc)

    def process_result_value(self, value, dialect):
        """Process value retrieving from database."""
        if value is None:
            return None

        if not isinstance(value, datetime):
            msg = f"ERROR: value: {value} is not of type datetime."
            raise ValueError(msg)

        if value.tzinfo not in (None, timezone.utc):
            msg = (
                f"Error: value: {value} doesn't have a tzinfo of None or timezone.utc."
            )
            raise ValueError(msg)

        return value.replace(tzinfo=timezone.utc)


class Timestamp:
    """Adds `created` and `updated` columns to a derived declarative model.

    The `created` column is handled through a default and the `updated`
    column is handled through a `before_update` event that propagates
    for all derived declarative models.

    ::

        from invenio_db import db
        class SomeModel(Base, db.Timestamp):
            __tablename__ = "somemodel"
            id = sa.Column(sa.Integer, primary_key=True)
    """

    created = Column(
        UTCDateTime,
        default=lambda: datetime.now(tz=timezone.utc),
        nullable=False,
    )
    updated = Column(
        UTCDateTime,
        default=lambda: datetime.now(tz=timezone.utc),
        nullable=False,
    )


@event.listens_for(Timestamp, "before_update", propagate=True)
def timestamp_before_update(mapper, connection, target):
    """Update timestamp on before_update event.

    When a model with a timestamp is updated; force update the updated
    timestamp.
    """
    target.updated = datetime.now(tz=timezone.utc)


class SQLAlchemy(FlaskSQLAlchemy):
    """Implement or overide extension methods."""

    def __getattr__(self, name):
        """Get attr."""
        if name == "UTCDateTime":
            return UTCDateTime

        if name == "Timestamp":
            return Timestamp

        return super().__getattr__(name)

    def apply_driver_hacks(self, app, sa_url, options):
        """Call before engine creation."""
        # Don't forget to apply hacks defined on parent object.
        super(SQLAlchemy, self).apply_driver_hacks(app, sa_url, options)

        if sa_url.drivername == "sqlite":
            connect_args = options.setdefault("connect_args", {})

            if "isolation_level" not in connect_args:
                # disable pysqlite's emitting of the BEGIN statement entirely.
                # also stops it from emitting COMMIT before any DDL.
                connect_args["isolation_level"] = None

            if not event.contains(Engine, "connect", do_sqlite_connect):
                event.listen(Engine, "connect", do_sqlite_connect)
            if not event.contains(Engine, "begin", do_sqlite_begin):
                event.listen(Engine, "begin", do_sqlite_begin)

            from sqlite3 import register_adapter

            def adapt_proxy(proxy):
                """Get current object and try to adapt it again."""
                return proxy._get_current_object()

            register_adapter(LocalProxy, adapt_proxy)

        elif sa_url.drivername == "postgresql+psycopg2":  # pragma: no cover
            from psycopg2.extensions import adapt, register_adapter

            def adapt_proxy(proxy):
                """Get current object and try to adapt it again."""
                return adapt(proxy._get_current_object())

            register_adapter(LocalProxy, adapt_proxy)

        elif sa_url.drivername == "mysql+pymysql":  # pragma: no cover
            from pymysql import converters

            def escape_local_proxy(val, mapping):
                """Get current object and try to adapt it again."""
                return converters.escape_item(
                    val._get_current_object(),
                    self.engine.dialect.encoding,
                    mapping=mapping,
                )

            converters.conversions[LocalProxy] = escape_local_proxy
            converters.encoders[LocalProxy] = escape_local_proxy

        return sa_url, options


def do_sqlite_connect(dbapi_connection, connection_record):
    """Ensure SQLite checks foreign key constraints.

    For further details see "Foreign key support" sections on
    https://docs.sqlalchemy.org/en/latest/dialects/sqlite.html#foreign-key-support
    """
    # Enable foreign key constraint checking
    cursor = dbapi_connection.cursor()
    cursor.execute("PRAGMA foreign_keys=ON")
    cursor.close()


def do_sqlite_begin(dbapi_connection):
    """Ensure SQLite transaction are started properly.

    For further details see "Foreign key support" sections on
    https://docs.sqlalchemy.org/en/rel_1_0/dialects/sqlite.html#pysqlite-serializable # noqa
    """
    # emit our own BEGIN
    dbapi_connection.execute(text("BEGIN"))


db = SQLAlchemy(metadata=metadata)
"""Shared database instance using Flask-SQLAlchemy extension.

This object is initialized during initialization of ``InvenioDB``
extenstion that takes care about loading all entrypoints from key
``invenio_db.models``.
"""
