import logging
import os
import shutil
from pathlib import Path

import alembic
import alembic.config
import alembic.command
from pyramid.paster import get_appsettings
from pyramid.scripting import prepare
from pyramid.testing import DummyRequest, testConfig
import pytest
import transaction
import webtest

from sqlalchemy import delete, inspect, select

from fietsboek import main, models
from fietsboek.data import DataManager
from fietsboek.models.meta import Base


def pytest_addoption(parser):
    parser.addoption('--ini', action='store', metavar='INI_FILE')

@pytest.fixture(scope='session')
def ini_file(request):
    # potentially grab this path from a pytest option
    return os.path.abspath(request.config.option.ini or 'testing.ini')

# Even though the ini file is scoped to session, we scope the actual settings
# to module only. This way, we can test different configurations in different
# modules.
@pytest.fixture(scope='module')
def app_settings(ini_file):
    return get_appsettings(ini_file)

@pytest.fixture(scope='module')
def dbengine(app_settings, ini_file):
    engine = models.get_engine(app_settings)

    alembic_cfg = alembic.config.Config(ini_file)
    Base.metadata.drop_all(bind=engine)
    alembic.command.stamp(alembic_cfg, None, purge=True)

    # run migrations to initialize the database
    # depending on how we want to initialize the database from scratch
    # we could alternatively call:
    # Base.metadata.create_all(bind=engine)
    # alembic.command.stamp(alembic_cfg, "head")
    alembic.command.upgrade(alembic_cfg, "head")

    yield engine

    Base.metadata.drop_all(bind=engine)
    alembic.command.stamp(alembic_cfg, None, purge=True)

@pytest.fixture
def data_manager(app_settings):
    return DataManager(Path(app_settings["fietsboek.data_dir"]))

@pytest.fixture(autouse=True)
def _cleanup_data(app_settings):
    yield
    engine = models.get_engine(app_settings)
    db_meta = inspect(engine)
    with engine.begin() as connection:
        for table in reversed(Base.metadata.sorted_tables):
            # The unit tests don't always set up the tables, so be gentle when
            # tearing them down
            if db_meta.has_table(table.name):
                connection.execute(table.delete())
    # The unit tests also often don't have a data directory, so be gentle here as well
    if "fietsboek.data_dir" in app_settings:
        data_dir = Path(app_settings["fietsboek.data_dir"])
        if (data_dir / "tracks").is_dir():
            shutil.rmtree(data_dir / "tracks")
        if (data_dir / "users").is_dir():
            shutil.rmtree(data_dir / "users")

@pytest.fixture(scope='module')
def app(app_settings, dbengine, tmp_path_factory):
    app_settings["fietsboek.data_dir"] = str(tmp_path_factory.mktemp("data"))
    logging.getLogger().setLevel(logging.DEBUG)
    return main({}, dbengine=dbengine, **app_settings)

@pytest.fixture
def tm():
    tm = transaction.TransactionManager(explicit=True)
    tm.begin()
    tm.doom()

    yield tm

    tm.abort()

@pytest.fixture
def dbsession(app, tm):
    session_factory = app.registry['dbsession_factory']
    return models.get_tm_session(session_factory, tm)

@pytest.fixture
def testapp(app, tm, dbsession):
    # override request.dbsession and request.tm with our own
    # externally-controlled values that are shared across requests but aborted
    # at the end
    testapp = webtest.TestApp(app, extra_environ={
        'HTTP_HOST': 'example.com',
        'tm.active': True,
        'tm.manager': tm,
        'app.dbsession': dbsession,
    })

    return testapp

@pytest.fixture
def app_request(app, tm, dbsession):
    """
    A real request.

    This request is almost identical to a real request but it has some
    drawbacks in tests as it's harder to mock data and is heavier.

    """
    with prepare(registry=app.registry) as env:
        request = env['request']
        request.host = 'example.com'

        # without this, request.dbsession will be joined to the same transaction
        # manager but it will be using a different sqlalchemy.orm.Session using
        # a separate database transaction
        request.dbsession = dbsession
        request.tm = tm

        yield request

@pytest.fixture
def dummy_request(tm, dbsession):
    """
    A lightweight dummy request.

    This request is ultra-lightweight and should be used only when the request
    itself is not a large focus in the call-stack.  It is much easier to mock
    and control side-effects using this object, however:

    - It does not have request extensions applied.
    - Threadlocals are not properly pushed.

    """
    request = DummyRequest()
    request.host = 'example.com'
    request.dbsession = dbsession
    request.tm = tm

    return request

@pytest.fixture
def dummy_config(dummy_request):
    """
    A dummy :class:`pyramid.config.Configurator` object.  This allows for
    mock configuration, including configuration for ``dummy_request``, as well
    as pushing the appropriate threadlocals.

    """
    with testConfig(request=dummy_request) as config:
        yield config

@pytest.fixture
def route_path(app_request):
    """
    A fixture that yields a function to generate route paths.

    This is equivalent to calling request.route_path on a request.
    """
    def get_route_path(*args, **kwargs):
        return app_request.route_path(*args, **kwargs)
    return get_route_path


@pytest.fixture()
def logged_in(testapp, route_path, dbsession, tm):
    """
    A fixture that represents a logged in state.

    This automatically creates a user and returns the created user.

    Returns the user that was logged in.
    """
    tm.abort()

    with tm:
        user = models.User(email='foo@barre.com', is_verified=True)
        user.set_password("foobar")
        dbsession.add(user)
        dbsession.flush()
        user_id = user.id

    tm.begin()
    tm.doom()

    login = testapp.get(route_path('login'))
    form = login.form
    form['email'] = 'foo@barre.com'
    form['password'] = 'foobar'
    response = form.submit()
    assert response.status_code == 302

    try:
        # Make sure to return an object that is not bound to the wrong db
        # session by re-fetching it with the proper fixture session:
        yield dbsession.execute(select(models.User).filter_by(id=user_id)).scalar_one()
    finally:
        tm.abort()
        with tm:
            dbsession.execute(delete(models.User).filter_by(id=user_id))
        tm.begin()
        tm.doom()