"""
logbook.ticketing
~~~~~~~~~~~~~~~~~

Implements long handlers that write to remote data stores and assign
each logging message a ticket id.

:copyright: (c) 2010 by Armin Ronacher, Georg Brandl.
:license: BSD, see LICENSE for more details.
"""

import json
from time import time

from logbook.base import NOTSET, LogRecord, level_name_property
from logbook.handlers import Handler, HashingHandlerMixin
from logbook.helpers import cached_property


class Ticket:
    """Represents a ticket from the database."""

    level_name = level_name_property()

    def __init__(self, db, row):
        self.db = db
        self.__dict__.update(row._mapping)

    @cached_property
    def last_occurrence(self):
        """The last occurrence."""
        if rv := self.get_occurrences(limit=1):
            return rv[0]

    def get_occurrences(self, order_by="-time", limit=50, offset=0):
        """Returns the occurrences for this ticket."""
        return self.db.get_occurrences(self.ticket_id, order_by, limit, offset)

    def solve(self):
        """Marks this ticket as solved."""
        self.db.solve_ticket(self.ticket_id)
        self.solved = True

    def delete(self):
        """Deletes the ticket from the database."""
        self.db.delete_ticket(self.ticket_id)

    # Silence DeprecationWarning
    __hash__ = None

    def __eq__(self, other):
        equal = True
        for key in self.__dict__.keys():
            if getattr(self, key) != getattr(other, key):
                equal = False
                break
        return equal

    def __ne__(self, other):
        return not self.__eq__(other)


class Occurrence(LogRecord):
    """Represents an occurrence of a ticket."""

    def __init__(self, db, row):
        self.update_from_dict(json.loads(row.data))
        self.db = db
        self.time = row.time
        self.ticket_id = row.ticket_id
        self.occurrence_id = row.occurrence_id


class BackendBase:
    """Provides an abstract interface to various databases."""

    def __init__(self, **options):
        self.options = options
        self.setup_backend()

    def setup_backend(self):
        """Setup the database backend."""
        raise NotImplementedError()

    def record_ticket(self, record, data, hash, app_id):
        """Records a log record as ticket."""
        raise NotImplementedError()

    def count_tickets(self):
        """Returns the number of tickets."""
        raise NotImplementedError()

    def get_tickets(self, order_by="-last_occurrence_time", limit=50, offset=0):
        """Selects tickets from the database."""
        raise NotImplementedError()

    def solve_ticket(self, ticket_id):
        """Marks a ticket as solved."""
        raise NotImplementedError()

    def delete_ticket(self, ticket_id):
        """Deletes a ticket from the database."""
        raise NotImplementedError()

    def get_ticket(self, ticket_id):
        """Return a single ticket with all occurrences."""
        raise NotImplementedError()

    def get_occurrences(self, ticket, order_by="-time", limit=50, offset=0):
        """Selects occurrences from the database for a ticket."""
        raise NotImplementedError()


class SQLAlchemyBackend(BackendBase):
    """Implements a backend that is writing into a database SQLAlchemy can
    interface.

    This backend takes some additional options:

    `table_prefix`
        an optional table prefix for all tables created by
        the logbook ticketing handler.

    `metadata`
        an optional SQLAlchemy metadata object for the table creation.

    `autocreate_tables`
        can be set to `False` to disable the automatic
        creation of the logbook tables.

    """

    def setup_backend(self):
        from sqlalchemy import MetaData, create_engine
        from sqlalchemy.orm import scoped_session, sessionmaker

        engine_or_uri = self.options.pop("uri", None)
        metadata = self.options.pop("metadata", None)
        table_prefix = self.options.pop("table_prefix", "logbook_")

        if hasattr(engine_or_uri, "execute"):
            self.engine = engine_or_uri
        else:
            # Pool recycle keeps connections from going stale,
            # which happens in MySQL Databases
            # Pool size is more custom for out stack
            self.engine = create_engine(engine_or_uri, pool_recycle=360, pool_size=1000)

            # Create session factory using session maker
            session = sessionmaker()

            # Bind to the engined
            session.configure(bind=self.engine)

            # Scoped session is a thread safe solution for
            # interaction with the Database
            self.session = scoped_session(session)

        if metadata is None:
            metadata = MetaData()
        self.table_prefix = table_prefix
        self.metadata = metadata
        self.create_tables()
        if self.options.get("autocreate_tables", True):
            self.metadata.create_all(bind=self.engine)

    def create_tables(self):
        """Creates the tables required for the handler on the class and
        metadata.
        """
        import sqlalchemy as db

        def table(name, *args, **kwargs):
            return db.Table(self.table_prefix + name, self.metadata, *args, **kwargs)

        self.tickets = table(
            "tickets",
            db.Column("ticket_id", db.Integer, primary_key=True),
            db.Column("record_hash", db.String(40), unique=True),
            db.Column("level", db.Integer),
            db.Column("channel", db.String(120)),
            db.Column("location", db.String(512)),
            db.Column("module", db.String(256)),
            db.Column("last_occurrence_time", db.DateTime),
            db.Column("occurrence_count", db.Integer),
            db.Column("solved", db.Boolean),
            db.Column("app_id", db.String(80)),
        )
        self.occurrences = table(
            "occurrences",
            db.Column("occurrence_id", db.Integer, primary_key=True),
            db.Column(
                "ticket_id",
                db.Integer,
                db.ForeignKey(self.table_prefix + "tickets.ticket_id"),
            ),
            db.Column("time", db.DateTime),
            db.Column("data", db.Text),
            db.Column("app_id", db.String(80)),
        )

    def _order(self, q, table, order_by):
        if order_by[0] == "-":
            return q.order_by(table.c[order_by[1:]].desc())
        return q.order_by(table.c[order_by])

    def record_ticket(self, record, data, hash, app_id):
        """Records a log record as ticket."""
        # Can use the session instead engine.connection and transaction
        s = self.session
        try:
            q = self.tickets.select().where(self.tickets.c.record_hash == hash)
            row = s.execute(q).one_or_none()
            if row is None:
                row = s.execute(
                    self.tickets.insert().values(
                        record_hash=hash,
                        level=record.level,
                        channel=record.channel or "",
                        location="%s:%d" % (record.filename, record.lineno),  # noqa: UP031
                        module=record.module or "<unknown>",
                        occurrence_count=0,
                        solved=False,
                        app_id=app_id,
                    )
                )
                ticket_id = row.inserted_primary_key[0]
            else:
                ticket_id = row.ticket_id
            s.execute(
                self.occurrences.insert().values(
                    ticket_id=ticket_id,
                    time=record.time,
                    app_id=app_id,
                    data=json.dumps(data),
                )
            )
            s.execute(
                self.tickets.update()
                .where(self.tickets.c.ticket_id == ticket_id)
                .values(
                    occurrence_count=self.tickets.c.occurrence_count + 1,
                    last_occurrence_time=record.time,
                    solved=False,
                )
            )
            s.commit()
        except Exception:
            s.rollback()
            raise
        # Closes the session and removes it from the pool
        s.remove()

    def count_tickets(self):
        """Returns the number of tickets."""
        from sqlalchemy import func, select

        with self.engine.begin() as conn:
            return conn.scalar(select(func.count()).select_from(self.tickets))

    def get_tickets(self, order_by="-last_occurrence_time", limit=50, offset=0):
        """Selects tickets from the database."""
        with self.engine.begin() as conn:
            return [
                Ticket(self, row)
                for row in conn.execute(
                    self._order(self.tickets.select(), self.tickets, order_by)
                    .limit(limit)
                    .offset(offset)
                )
            ]

    def solve_ticket(self, ticket_id):
        """Marks a ticket as solved."""
        with self.engine.begin() as conn:
            conn.execute(
                self.tickets.update()
                .where(self.tickets.c.ticket_id == ticket_id)
                .values(solved=True)
            )

    def delete_ticket(self, ticket_id):
        """Deletes a ticket from the database."""
        with self.engine.begin() as conn:
            conn.execute(
                self.occurrences.delete().where(
                    self.occurrences.c.ticket_id == ticket_id
                )
            )
            conn.execute(
                self.tickets.delete().where(self.tickets.c.ticket_id == ticket_id)
            )

    def get_ticket(self, ticket_id):
        """Return a single ticket with all occurrences."""
        with self.engine.begin() as conn:
            row = conn.execute(
                self.tickets.select().where(self.tickets.c.ticket_id == ticket_id)
            ).one_or_none()
        if row is not None:
            return Ticket(self, row)

    def get_occurrences(self, ticket, order_by="-time", limit=50, offset=0):
        """Selects occurrences from the database for a ticket."""
        with self.engine.begin() as conn:
            return [
                Occurrence(self, row)
                for row in conn.execute(
                    self._order(
                        self.occurrences.select().where(
                            self.occurrences.c.ticket_id == ticket
                        ),
                        self.occurrences,
                        order_by,
                    )
                    .limit(limit)
                    .offset(offset)
                )
            ]


class MongoDBBackend(BackendBase):
    """Implements a backend that writes into a MongoDB database."""

    class _FixedTicketClass(Ticket):
        @property
        def ticket_id(self):
            return self._id

    class _FixedOccurrenceClass(Occurrence):
        def __init__(self, db, row):
            self.update_from_dict(json.loads(row["data"]))
            self.db = db
            self.time = row["time"]
            self.ticket_id = row["ticket_id"]
            self.occurrence_id = row["_id"]

    # TODO: Update connection setup once PYTHON-160 is solved.
    def setup_backend(self):
        from pymongo import ASCENDING, DESCENDING
        from pymongo.connection import Connection

        try:
            from pymongo.uri_parser import parse_uri
        except ImportError:
            from pymongo.connection import _parse_uri as parse_uri

        from pymongo.errors import AutoReconnect

        _connection = None
        uri = self.options.pop("uri", "")
        _connection_attempts = 0

        parsed_uri = parse_uri(uri, Connection.PORT)

        if type(parsed_uri) is tuple:
            # pymongo < 2.0
            database = parsed_uri[1]
        else:
            # pymongo >= 2.0
            database = parsed_uri["database"]

        # Handle auto reconnect signals properly
        while _connection_attempts < 5:
            try:
                if _connection is None:
                    _connection = Connection(uri)
                database = _connection[database]
                break
            except AutoReconnect:
                _connection_attempts += 1
                time.sleep(0.1)

        self.database = database

        # setup correct indexes
        database.tickets.ensure_index([("record_hash", ASCENDING)], unique=True)
        database.tickets.ensure_index([("solved", ASCENDING), ("level", ASCENDING)])
        database.occurrences.ensure_index([("time", DESCENDING)])

    def _order(self, q, order_by):
        from pymongo import ASCENDING, DESCENDING

        col = "%s" % (order_by[1:] if order_by[0] == "-" else order_by)
        if order_by[0] == "-":
            return q.sort(col, DESCENDING)
        return q.sort(col, ASCENDING)

    def _oid(self, ticket_id):
        from pymongo.objectid import ObjectId

        return ObjectId(ticket_id)

    def record_ticket(self, record, data, hash, app_id):
        """Records a log record as ticket."""
        db = self.database
        ticket = db.tickets.find_one({"record_hash": hash})
        if not ticket:
            doc = {
                "record_hash": hash,
                "level": record.level,
                "channel": record.channel or "",
                "location": "%s:%d" % (record.filename, record.lineno),  # noqa: UP031
                "module": record.module or "<unknown>",
                "occurrence_count": 0,
                "solved": False,
                "app_id": app_id,
            }
            ticket_id = db.tickets.insert(doc)
        else:
            ticket_id = ticket["_id"]

        db.tickets.update(
            {"_id": ticket_id},
            {
                "$inc": {"occurrence_count": 1},
                "$set": {"last_occurrence_time": record.time, "solved": False},
            },
        )
        # We store occurrences in a seperate collection so that
        # we can make it a capped collection optionally.
        db.occurrences.insert(
            {
                "ticket_id": self._oid(ticket_id),
                "app_id": app_id,
                "time": record.time,
                "data": json.dumps(data),
            }
        )

    def count_tickets(self):
        """Returns the number of tickets."""
        return self.database.tickets.count()

    def get_tickets(self, order_by="-last_occurrence_time", limit=50, offset=0):
        """Selects tickets from the database."""
        query = (
            self._order(self.database.tickets.find(), order_by)
            .limit(limit)
            .skip(offset)
        )
        return [self._FixedTicketClass(self, obj) for obj in query]

    def solve_ticket(self, ticket_id):
        """Marks a ticket as solved."""
        self.database.tickets.update({"_id": self._oid(ticket_id)}, {"solved": True})

    def delete_ticket(self, ticket_id):
        """Deletes a ticket from the database."""
        self.database.occurrences.remove({"ticket_id": self._oid(ticket_id)})
        self.database.tickets.remove({"_id": self._oid(ticket_id)})

    def get_ticket(self, ticket_id):
        """Return a single ticket with all occurrences."""
        if ticket := self.database.tickets.find_one({"_id": self._oid(ticket_id)}):
            return Ticket(self, ticket)

    def get_occurrences(self, ticket, order_by="-time", limit=50, offset=0):
        """Selects occurrences from the database for a ticket."""
        collection = self.database.occurrences
        occurrences = (
            self._order(collection.find({"ticket_id": self._oid(ticket)}), order_by)
            .limit(limit)
            .skip(offset)
        )
        return [self._FixedOccurrenceClass(self, obj) for obj in occurrences]


class TicketingBaseHandler(Handler, HashingHandlerMixin):
    """Baseclass for ticketing handlers.  This can be used to interface
    ticketing systems that do not necessarily provide an interface that
    would be compatible with the :class:`BackendBase` interface.
    """

    def __init__(self, hash_salt, level=NOTSET, filter=None, bubble=False):
        Handler.__init__(self, level, filter, bubble)
        self.hash_salt = hash_salt

    def hash_record_raw(self, record):
        """Returns the unique hash of a record."""
        hash = HashingHandlerMixin.hash_record_raw(self, record)
        if self.hash_salt is not None:
            hash_salt = self.hash_salt
            hash_salt = hash_salt.encode("utf-8")
            hash.update(b"\x00" + hash_salt)
        return hash


class TicketingHandler(TicketingBaseHandler):
    """A handler that writes log records into a remote database.  This
    database can be connected to from different dispatchers which makes
    this a nice setup for web applications::

        from logbook.ticketing import TicketingHandler

        handler = TicketingHandler("sqlite:////tmp/myapp-logs.db")

    :param uri: a backend specific string or object to decide where to log to.
    :param app_id: a string with an optional ID for an application.  Can be
                   used to keep multiple application setups apart when logging
                   into the same database.
    :param hash_salt: an optional salt (binary string) for the hashes.
    :param backend: A backend class that implements the proper database
                    handling.
                    Backends available are: :class:`SQLAlchemyBackend`,
                    :class:`MongoDBBackend`.
    """

    #: The default backend that is being used when no backend is specified.
    #: Unless overriden by a subclass this will be the
    #: :class:`SQLAlchemyBackend`.
    default_backend = SQLAlchemyBackend

    def __init__(
        self,
        uri,
        app_id="generic",
        level=NOTSET,
        filter=None,
        bubble=False,
        hash_salt=None,
        backend=None,
        **db_options,
    ):
        if hash_salt is None:
            hash_salt = "apphash-" + app_id
        TicketingBaseHandler.__init__(self, hash_salt, level, filter, bubble)
        if backend is None:
            backend = self.default_backend
        db_options["uri"] = uri
        self.set_backend(backend, **db_options)
        self.app_id = app_id

    def set_backend(self, cls, **options):
        self.db = cls(**options)

    def process_record(self, record, hash):
        """Subclasses can override this to tamper with the data dict that
        is sent to the database as JSON.
        """
        return record.to_dict(json_safe=True)

    def record_ticket(self, record, data, hash):
        """Record either a new ticket or a new occurrence for a
        ticket based on the hash.
        """
        self.db.record_ticket(record, data, hash, self.app_id)

    def emit(self, record):
        """Emits a single record and writes it to the database."""
        hash = self.hash_record(record).encode("utf-8")
        data = self.process_record(record, hash)
        self.record_ticket(record, data, hash)
