# SQLAlchemy plugins for cherrypy
# Copyright (C) 2022-2026 IKUS Software
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
import logging
import re

import cherrypy
from cherrypy.process.plugins import SimplePlugin
from sqlalchemy import create_engine, event, inspect
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker

from cherrypy_foundation.plugins._sqlite_functions import register_all_functions

CONTEXT_DB = 'DB'


@event.listens_for(Engine, 'connect')
def _on_sqlite_connect(connection, connection_record):
    if 'sqlite3' in str(connection.__class__):
        # Register expected regex implementation.
        register_all_functions(connection)
        # Enable WAL journaling for concurrent read and write operation.
        cursor = connection.cursor()
        cursor.execute('PRAGMA journal_mode=WAL;')
        # Enforce foreign_keys
        cursor.execute("PRAGMA foreign_keys=ON;")
        cursor.close()


@event.listens_for(Engine, "handle_error")
def enhance_database_errors(exception_context):
    """
    Enhance SQLAlchemy error by adding more information.
    """
    if isinstance(exception_context.sqlalchemy_exception, IntegrityError):
        constraint = _find_constraint(exception_context.sqlalchemy_exception)
        exception_context.sqlalchemy_exception.constraint = constraint


def _find_constraint(exception):
    """
    Search reference of constraint within the error message. Return None if not found.
    """
    # Extract the constraint name for Postgresql and SQLite
    # Postgresql: duplicate key value violates unique constraint "subnet_name_key"\nDETAIL:  Key (name)=() already exists.\n
    # Postgresql: violates check constraint "dnsrecord_value_domain_name"
    # Postgresql: foreign key constraint "dnsrecord_dnszone_subnet_fk"
    # SQLite: UNIQUE constrain: subnet.name
    # SQLite: UNIQUE constraint failed: index 'dnszone_name_index'
    # SQLite: CHECK constraint failed: dnsrecord_value_domain_name
    error = exception._message()
    constraint_match = (
        re.search(r'unique constraint "([^"]+)"', error)
        or re.search(r'check constraint "([^"]+)"', error)
        or re.search(r"UNIQUE constraint failed: index '([^']+)'", error)
        or re.search(r"UNIQUE constraint failed: (.+)$", error)
        or re.search(r"CHECK constraint failed: (.+)", error)
        or re.search(r'foreign key constraint "([^"]+)"', error)
    )
    if not constraint_match:
        return None
    name = constraint_match[1]

    # Use a lookup cache to simplify the search of index and constraints.
    if not getattr(_find_constraint, '_cache', False):
        cache = {}
        metadata = cherrypy.db.base.metadata
        for table in metadata.tables.values():
            for item in table.constraints:
                if item.name:
                    cache[item.name] = item
            for item in table.indexes:
                # Keep reference to unique index only.
                if item.unique:
                    if item.name:
                        cache[item.name] = item
                    # SQLite return <table>.<column>
                    key = ', '.join([f'{table.name}.{c.name}' for c in item.columns])
                    cache[key] = item
        _find_constraint._cache = cache

    return _find_constraint._cache.get(name, None)


class Base:
    '''
    Extends declarative base to provide convenience methods to models similar to
    functionality found in Elixir. Works in python3.

    For example, given the model User:
    # no need to write init methods for models, simply pass keyword arguments or
    # override if needed.
    User(name="daniel", email="daniel@dasa.cc").add()
    User.query # returns session.query(User)
    User.query.all() # instead of session.query(User).all()
    changed = User.from_dict({}) # update record based on dict argument passed in and returns any keys changed
    '''

    #
    # CRUD Shortcuts
    #
    def add(self):
        """Add current object to session."""
        self.__class__.session.add(self)
        return self

    def delete(self):
        """Mark current object for deletion."""
        self.__class__.session.delete(self)
        return self

    def commit(self):
        """Commit current session."""
        self.__class__.session.commit()
        return self

    def flush(self):
        """Flush current session."""
        self.__class__.session.flush()
        return self

    def expire(self):
        """Expire current object from session."""
        self.__class__.session.expire(self)
        return self

    def rollback(self):
        """Rollback current session."""
        self.__class__.session.rollback()
        return self

    #
    # Change tracking
    #

    def get_changes(self) -> tuple[str, dict]:
        """
        Return a tuple of (change_type, changes) for the current model instance
        since it was fetched from the database.

        change_type is one of:
            - 'new'     : object not yet persisted in DB
            - 'dirty'   : object exists in DB and has pending changes
            - 'deleted' : object is marked for deletion

        changes is a dictionary of the form:
            {'property_name': [old_value, new_value]}

        Fields can be excluded from tracking by setting info={'change_ignore': True}
        on the column definition:

            class User(Base):
                __tablename__ = 'users'
                password = Column(String, info={'change_ignore': True})

        Relationship / array changes are stored as:
            {'roles': [old_collection, new_collection]}

        Primitive changes are stored as:
            {'name': ['Alice', 'Bob']}
        """
        state = inspect(self)
        mapper = state.mapper
        changes = {}

        for attr in state.attrs:
            # Skip fields marked as change_ignore
            if mapper.has_property(attr.key) and mapper.get_property(attr.key).info.get('change_ignore', False):
                continue

            # load_history() ensures history is loaded from DB if not already
            # vs attr.history which only checks in-memory state
            hist = attr.load_history()

            if not hist.has_changes():
                continue

            if isinstance(attr.value, (list, tuple)) or len(hist.deleted) > 1 or len(hist.added) > 1:
                # Relationship / array field
                changes[attr.key] = [hist.deleted, hist.added]
            else:
                # Primitive field
                changes[attr.key] = [
                    hist.deleted[0] if hist.deleted else None,
                    hist.added[0] if hist.added else None,
                ]

        # Determine the type of change
        if self in state.session.deleted:
            change_type = 'deleted'
        elif not state.has_identity:
            change_type = 'new'
        elif changes:
            change_type = 'dirty'
        else:
            change_type = 'clean'  # exists in DB, no changes

        return change_type, changes


class SQLA(SimplePlugin):
    uri = None
    debug = False
    autoflush = False
    autocommit = False
    autobegin = True
    # When True, automatically call create_all() on start.
    autocreate = False

    base = declarative_base(cls=Base)
    session = None
    engine = None

    def start(self):
        if self.uri is None:
            return
        # Adjust debug level.
        if self.debug:
            logging.getLogger('sqlalchemy.engine').setLevel(logging.DEBUG)
        # Invalidate cache on restart
        if hasattr(_find_constraint, '_cache'):
            del _find_constraint._cache
        # Create connection to database
        self.engine = create_engine(self.uri)
        # Clean-up previous session.
        self.clear_sessions()
        # Associate our session to our engine
        self._session_factory = sessionmaker(
            autoflush=self.autoflush, autocommit=self.autocommit, autobegin=self.autobegin
        )
        self.session = scoped_session(self._session_factory)
        self.session.configure(bind=self.engine)
        # Provide a friendly ObjectName.query.
        self.base.session = self.session
        self.base.query = self.session.query_property()
        # Register our `before_error_response` callback very early to be present for all request.
        cherrypy.request.hooks.attach("before_error_response", self.before_error_response)
        cherrypy.request.hooks.attach("before_finalize", self.before_finalize)
        cherrypy.request.hooks.attach("on_end_request", self.on_end_request)
        # Auto-create
        if self.autocreate:
            self.bus.log("autocreate=True: creating database tables")
            self.create_all()
        # Plugins started.
        self.bus.log("database session plugin started")

    # This is slightly lower priority to get database started first.
    start.priority = 45

    def stop(self):
        # Unregister our `before_error_response` callback
        hooks = list(cherrypy.request.hooks['before_error_response'])
        for hook in hooks:
            if hook.callback == self.before_error_response:
                cherrypy.request.hooks['before_error_response'].remove(hook)
        # Clear all sessions.
        if self.session:
            self.clear_sessions()
            self.session = None
        if self.engine:
            self.engine.dispose()
            self.engine = None
        self.bus.log("database session plugin stopped")

    def graceful(self):
        """Reload of subscribers."""
        self.stop()
        self.start()

    def create_all(self, commit=True):
        """
        Create all tables defined in the metadata.
        Clears the session afterward since DDL invalidates all tracked ORM objects.
        """
        # Create tables
        conn = self.session.connection()
        self.base.metadata.create_all(bind=conn)
        # Critical: expunge all tracked objects since DDL may have invalidated them
        if commit:
            conn.commit()
            self.session.remove()
        self.bus.log("database tables created")

    def drop_all(self, commit=True):
        """
        Drop all tables. Clears session for same reason as create_all().
        """
        conn = self.session.connection()
        self.base.metadata.drop_all(bind=conn)
        # Critical: same reason
        if commit:
            conn.commit()
            self.session.remove()
        self.bus.log("database tables dropped")

    def before_finalize(self):
        """
        After request, always commit to follow one transaction per-request principle.
        """
        if self.session is None:
            return
        # Exception will trigger `before_error_response`.
        if self.session.registry().in_transaction():
            self.session.commit()

    def on_end_request(self):
        """
        Always runs - ensure session is closed
        """
        if self.session is None:
            return
        self.session.remove()

    def before_error_response(self):
        """
        On server error, let rollback.
        """
        if self.session is None:
            return
        cherrypy.log("rolling back due to error", severity=logging.ERROR, context=CONTEXT_DB)
        self.session.rollback()

    def clear_sessions(self):
        """
        Used to clean-up session.
        """
        if self.session is None:
            return
        if self.session.registry().in_transaction():
            self.session.rollback()
        self.session.remove()


cherrypy.db = SQLA(cherrypy.engine)
cherrypy.db.subscribe()

cherrypy.config.namespaces['db'] = lambda key, value: setattr(cherrypy.db, key, value)
