import multiprocessing
import os
import typing as t
import uuid
from contextlib import _GeneratorContextManager, contextmanager
from datetime import date, datetime, timedelta
from typing import Any, Generator, cast
from unittest.mock import patch

import pytest
from _pytest.fixtures import FixtureRequest
from flask import Flask, Request, Response, abort, template_rendered
from flask.sessions import SessionMixin
from flask.typing import ResponseReturnValue
from flask_login import login_user
from flask_migrate import upgrade
from flask_sqlalchemy_lite import SQLAlchemy
from flask_wtf import FlaskForm
from jinja2 import Template
from sqlalchemy import event
from sqlalchemy.engine import Connection
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import Session
from sqlalchemy.orm.session import SessionTransaction
from sqlalchemy_utils import create_database, database_exists
from testcontainers.community.postgres import PostgresContainer
from werkzeug.test import TestResponse

from app import create_app
from app.common.collections.types import TextSingleLineAnswer
from app.common.data.interfaces.collections import update_submission_data
from app.common.data.interfaces.system import seed_system_data
from app.common.data.models import GrantRecipient, Submission
from app.common.data.models_user import User
from app.common.data.types import (
    AuthMethodEnum,
    CollectionStatusEnum,
    GrantStatusEnum,
    QuestionDataType,
    RoleEnum,
    SubmissionEventType,
    SubmissionModeEnum,
    SubmissionStatusEnum,
)
from app.common.helpers.collections import SubmissionHelper
from app.common.helpers.submission_events import SubmissionEventHelper
from app.extensions.record_sqlalchemy_queries import QueryInfo, get_recorded_queries
from tests.conftest import FundingServiceTestClient, _Factories, _precompile_templates
from tests.integration.utils import TimeFreezer
from tests.models import FactoryAnswer
from tests.types import TemplateRenderRecord, TTemplatesRendered
from tests.utils import build_db_config


@pytest.fixture(scope="session")
def setup_db_container() -> Generator[PostgresContainer, None, None]:
    from testcontainers.core.config import testcontainers_config

    # Reduce sleep/wait time from 1 second to 0.1 seconds. We could drop this if it ever causes any problems, but shaves
    # off a little bit of time - why not.
    testcontainers_config.sleep_time = 0.1

    test_postgres = PostgresContainer("postgres:17.5")
    test_postgres.start()

    yield test_postgres

    test_postgres.stop()


@pytest.fixture(scope="session")
def db(setup_db_container: PostgresContainer, app: Flask) -> Generator[SQLAlchemy, None, None]:
    with app.app_context():
        no_db = not database_exists(app.config["SQLALCHEMY_ENGINES"]["default"])

        if no_db:
            create_database(app.config["SQLALCHEMY_ENGINES"]["default"])

        # Run alembic migrations. We do this is a separate python process because it loads and executes a bunch
        # of code from app/common/data/migrations/env.py. This does things like set up loggers, which interferes with
        # the `caplog` fixture, and possibly has some other unexpected side effects.
        ctx = multiprocessing.get_context("fork")  # spawn subprocess via fork, so it retains configuration/etc.
        proc = ctx.Process(target=upgrade)
        proc.start()
        proc.join()

    yield app.extensions["sqlalchemy"]

    with app.app_context():
        for engine in app.extensions["sqlalchemy"].engines.values():
            engine.dispose()


@pytest.fixture(scope="session")
def app(setup_db_container: PostgresContainer) -> Generator[Flask, None, None]:
    with patch.dict(
        os.environ,
        build_db_config(setup_db_container),
    ):
        app = create_app()

    @app.route("/_testing/403")
    def raise_403() -> ResponseReturnValue:
        return abort(403)

    @app.route("/access/_testing/403")
    def raise_access_403() -> ResponseReturnValue:
        return abort(403)

    @app.route("/deliver/_testing/403")
    def raise_deliver_403() -> ResponseReturnValue:
        return abort(403)

    @app.route("/_testing/500")
    def raise_500() -> ResponseReturnValue:
        return abort(500)

    @app.route("/access/_testing/500")
    def raise_access_500() -> ResponseReturnValue:
        return abort(500)

    @app.route("/deliver/_testing/500")
    def raise_deliver_500() -> ResponseReturnValue:
        return abort(500)

    @app.route("/_testing/sqlalchemy-not-found")
    def raise_sqlalchemy_not_found() -> ResponseReturnValue:
        # get a thing that doesn't exist
        try:
            app.extensions["sqlalchemy"].session.query(User).where(User.id == uuid.uuid4()).one()
        except Exception as e:
            if not isinstance(e, NoResultFound):
                return abort(500)
            raise e
        raise RuntimeError("query expected no results and an error, but didn't")

    @app.route("/custom-cache-control")
    def custom_cache_control() -> ResponseReturnValue:
        response = Response(mimetype="text/plain")
        response.headers["Cache-Control"] = "max-age=30"
        return response

    app.config.update({"TESTING": True})
    _precompile_templates(app)
    yield app


def _validate_form_argument_to_render_template(response: TestResponse, templates_rendered: TTemplatesRendered) -> None:
    if response.headers["content-type"].startswith("text/html"):
        for _endpoint, render_template in templates_rendered.items():
            # Don't check templates rendered by/for Flask-Admin - we don't control the `form` arg passed there.
            request = cast(Request, render_template.context.get("request"))
            if request and request.path.startswith("/deliver/admin"):
                continue

            if "form" in render_template.context and render_template.context["form"]:
                assert isinstance(render_template.context["form"], FlaskForm), (
                    "The `form` argument passed to `render_template` is expected to be a FlaskForm instance. "
                    "This powers 'magic' handling of error summary rendering."
                )


@pytest.fixture()
def anonymous_client(app: Flask, templates_rendered: TTemplatesRendered) -> FundingServiceTestClient:
    _setup_session_clean_tracking()  # setting up listeners

    class CustomClient(FundingServiceTestClient):
        # We want to be sure that any data methods that act during the request have been
        # committed by the flask app lifecycle before continuing. Because of the way we configure
        # savepoints and rollbacks for test isolation a `flush` is considered the same as a
        # `commit` as the same session configuration is used. Calling rollback after making requests
        # to the app under test will either revert to the previous savepoint (undoing any uncommitted flushes)
        # or leave the session unchanged if it was appropriately committed. This is to be used in conjunction with
        # the `db_session` fixture.
        def open(self, *args, **kwargs) -> TestResponse:
            kwargs.setdefault("headers", {})
            kwargs["headers"].setdefault("Host", "funding.communities.gov.localhost:8080")

            response = super().open(*args, **kwargs)
            _validate_form_argument_to_render_template(response, templates_rendered)

            # WARNING: this check is unreliable if using `follow_redirects=True`. When this is set, the FlaskClient
            # will seamlessly resolve any redirects and potentially fire off multiple requests. If the first request
            # doesn't commit properly, then subsequent requests either commit or rollback, the DB session state may
            # not remain "un-clean", leading to this not triggering.
            # Addressing this properly requires a bit more depth and finick than I'm interested in us taking here, for
            # what is supposed to be a "helpful" check rather than a "rock-solid" check.
            session = app.extensions["sqlalchemy"].session
            if not session.info.get("clean", True):
                # args[0] is not always a nicely-readable thing; it might be a dict or EnvironBuilder instance.
                # But it's sort-of-ok and easy-enough to grab for now to be helpful more often than not.
                method = kwargs.get("method") or (args[0] if args else "GET")
                path = kwargs.get("path") or (args[0] if args else "/")

                raise pytest.fail(
                    f"Detected uncommitted changes in the SQLAlchemy session after handling "
                    f"{method} request to {path}. "
                    f"To ensure database consistency, wrap your request handler with @auto_commit_after_request."
                )

            app.extensions["sqlalchemy"].session.rollback()
            return response

        @contextmanager
        def session_transaction(self, *args: t.Any, **kwargs: t.Any) -> t.Iterator[SessionMixin]:
            kwargs.setdefault("headers", {})
            kwargs["headers"].setdefault("Host", "funding.communities.gov.localhost:8080")

            with super().session_transaction(*args, **kwargs) as sess:
                yield sess

    app.test_client_class = CustomClient
    client = app.test_client()
    return t.cast(CustomClient, client)


@pytest.fixture(scope="session", autouse=True)
def _integration_test_timeout(request: FixtureRequest) -> None:
    """Fail tests under `tests/integration` if they take 'too long', to encourage us to maintain tests that are
    reasonably fast here.

    These tests may talk over the network (eg to the DB), so we need to make some allowance for that, but they should
    still be able to be fairly fast.
    """
    request.node.add_marker(
        pytest.mark.fail_slow(os.environ.get("INTEGRATION_TIMEOUT_MS", "500ms" if "CI" not in os.environ else "1000ms"))
    )


@pytest.fixture(scope="function", autouse=True)
def time_freezer(db_session: Session, request: FixtureRequest) -> Generator[TimeFreezer | None, None, None]:
    marker = request.node.get_closest_marker("freeze_time")
    if marker:
        fake_time = marker.args[0]
        time_freezer = TimeFreezer(fake_time, db_session)
        yield time_freezer
        time_freezer.restore_actual_time()
    else:
        yield None


@pytest.fixture(scope="function", autouse=True)
def db_session(app: Flask, db: SQLAlchemy) -> Generator[Session, None, None]:
    # Set up a DB session that is fully isolated for each specific test run. We override Flask-SQLAlchemy-Lite's (FSL)
    # sessionmaker configuration to use a connection with a transaction started, and configure FSL to use savepoints
    # for any flushes/commits that happen within the test. When the test finishes, this fixture will do a full rollback,
    # preventing any data leaking beyond the scope of the test.
    #
    # NOTE: this fixture is automatically used by all integration tests, and provides both an app context and a test
    # request context. So you will not need to manually create these within your integration tests.
    with app.app_context():
        seed_system_data(app)

    with app.app_context():
        connection = db.engine.connect()
        transaction = connection.begin()

        original_configuration = db.sessionmaker.kw.copy()
        db.sessionmaker.configure(bind=connection, join_transaction_mode="create_savepoint")
        try:
            yield db.session

        finally:
            # Restore the original sessionmaker configuration.
            db.sessionmaker.configure(**original_configuration)

            db.session.close()
            transaction.rollback()
            connection.close()


@pytest.fixture(scope="function")
def templates_rendered(app: Flask) -> Generator[TTemplatesRendered]:
    recorded: TTemplatesRendered = {}

    def record(sender: Flask, template: Template, context: dict[str, Any], **extra: dict[str, Any]) -> None:
        request = context.get("request")
        if template.name and "govuk_frontend_wtf" not in template.name and request and request.endpoint:
            endpoint = request.endpoint
            entry = TemplateRenderRecord(template=template, context=context)
            if endpoint not in recorded:
                recorded[endpoint] = entry

    template_rendered.connect(record, app)
    try:
        yield recorded
    finally:
        template_rendered.disconnect(record, app)


@pytest.fixture()
def authenticated_no_role_client(
    anonymous_client: FundingServiceTestClient, factories: _Factories, request: FixtureRequest, db_session: Session
) -> Generator[FundingServiceTestClient, None, None]:
    email_mark = request.node.get_closest_marker("authenticate_as")
    email = email_mark.args[0] if email_mark else "test@communities.gov.uk"

    user = factories.user.create(email=email)

    # `login_user(user)` is what we use to catch and update a user's `last_logged_in_at_utc` by looking out for flask
    # login's `user_logged_in` signal. In our app, these `login_user(user)` calls are only done in routes that use the
    # `auto_commit_after_request` decorator, but here we're not in an existing session and would be left with a dirty
    # session after this client is used in tests, so we need to commit this change to the user before we continue.
    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO

    anonymous_client.user = user
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_grant_member_client_no_grant_recipients(
    anonymous_client: FundingServiceTestClient,
    factories: _Factories,
    db_session: Session,
    request: FixtureRequest,
) -> Generator[FundingServiceTestClient, None, None]:
    email_mark = request.node.get_closest_marker("authenticate_as")
    email = email_mark.args[0] if email_mark else "test2@communities.gov.uk"

    user = factories.user.create(email=email)
    grant = factories.grant.create()
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.MEMBER],
        organisation=grant.organisation,
        grant=grant,
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = user
    anonymous_client.grant = grant
    anonymous_client.organisation = grant.organisation
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_grant_member_client(
    anonymous_client: FundingServiceTestClient,
    grant_recipient,
    factories: _Factories,
    db_session: Session,
    request: FixtureRequest,
) -> Generator[FundingServiceTestClient, None, None]:
    email_mark = request.node.get_closest_marker("authenticate_as")
    email = email_mark.args[0] if email_mark else "test2@communities.gov.uk"

    user = factories.user.create(email=email)
    grant = grant_recipient.grant
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.MEMBER],
        organisation=grant.organisation,
        grant=grant,
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = user
    anonymous_client.grant = grant
    anonymous_client.organisation = grant.organisation
    anonymous_client.grant_recipient = grant_recipient
    db_session.commit()

    yield anonymous_client


# TODO: combine (at least) the grant clients and allow the user+grant to come from another fixture so that we don't
#       need to attach them to the client; tests that want access to the configured user+grant could request the
#       fixture directly? Also maybe a pytest mark could be used to set the role provided for the authenticated grant
#       client rather than having two definitions.
@pytest.fixture()
def authenticated_grant_admin_client(
    anonymous_client: FundingServiceTestClient, factories: _Factories, db_session: Session, request: FixtureRequest
) -> Generator[FundingServiceTestClient, None, None]:
    email_mark = request.node.get_closest_marker("authenticate_as")
    email = email_mark.args[0] if email_mark else "test2@communities.gov.uk"

    user = factories.user.create(email=email)
    grant = factories.grant.create()
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.ADMIN],
        organisation=grant.organisation,
        grant=grant,
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = user
    anonymous_client.grant = grant
    anonymous_client.organisation = grant.organisation
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_platform_admin_client(
    anonymous_client: FundingServiceTestClient, factories: _Factories, db_session: Session, request: FixtureRequest
) -> Generator[FundingServiceTestClient, None, None]:
    email_mark = request.node.get_closest_marker("authenticate_as")
    email = email_mark.args[0] if email_mark else "test@communities.gov.uk"

    user = factories.user.create(email=email)
    factories.user_role.create(user_id=user.id, user=user, permissions=[RoleEnum.MEMBER, RoleEnum.ADMIN])
    grant = factories.grant.create()

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = user
    db_session.commit()

    anonymous_client.grant = grant

    yield anonymous_client


@pytest.fixture()
def authenticated_platform_grant_lifecycle_manager_client(
    anonymous_client: FundingServiceTestClient, factories: _Factories, db_session: Session, request: FixtureRequest
) -> Generator[FundingServiceTestClient, None, None]:
    """
    Create a client authenticated as a platform grant lifecycle manager (GRANT_COLLECTION_LIFECYCLE at platform level,
    No org/grant)
    """
    email_mark = request.node.get_closest_marker("authenticate_as")
    email = email_mark.args[0] if email_mark else "platformgrantlifecyclemanager@communities.gov.uk"

    user = factories.user.create(email=email)
    factories.user_role.create(
        user_id=user.id,
        user=user,
        permissions=[RoleEnum.MEMBER, RoleEnum.GRANT_LIFECYCLE_MANAGER],
        organisation=None,
        grant=None,
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = user
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_platform_data_analyst_client(
    anonymous_client: FundingServiceTestClient, factories: _Factories, db_session: Session, request: FixtureRequest
) -> Generator[FundingServiceTestClient, None, None]:
    """Create a client authenticated as a platform data analyst (DATA_ANALYST at platform level, no org/grant)"""
    email_mark = request.node.get_closest_marker("authenticate_as")
    email = email_mark.args[0] if email_mark else "platformdatanalyst@communities.gov.uk"

    user = factories.user.create(email=email)
    factories.user_role.create(
        user_id=user.id, user=user, permissions=[RoleEnum.MEMBER, RoleEnum.DATA_ANALYST], organisation=None, grant=None
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = user
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_platform_member_client(
    anonymous_client: FundingServiceTestClient, factories: _Factories, db_session: Session, request: FixtureRequest
) -> Generator[FundingServiceTestClient, None, None]:
    """Create a client authenticated as a platform member (MEMBER at platform level, no org/grant)"""
    email_mark = request.node.get_closest_marker("authenticate_as")
    email = email_mark.args[0] if email_mark else "platformmember@communities.gov.uk"

    user = factories.user.create(email=email)
    factories.user_role.create(user_id=user.id, user=user, permissions=[RoleEnum.MEMBER], organisation=None, grant=None)

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = user
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_org_admin_client(
    anonymous_client: FundingServiceTestClient, org_admin_user, factories: _Factories, db_session: Session
) -> Generator[FundingServiceTestClient, None, None]:
    """Create a client authenticated as an org admin for an org with can_manage_grants=True"""

    login_user(org_admin_user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = org_admin_user
    anonymous_client.organisation = org_admin_user.roles[0].organisation
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_org_member_client(
    anonymous_client: FundingServiceTestClient, factories: _Factories, db_session: Session
) -> Generator[FundingServiceTestClient, None, None]:
    """Create a client authenticated as an org member for an org with can_manage_grants=True"""
    from tests.models import _get_grant_managing_organisation

    user = factories.user.create(email="otheradmin@communities.gov.uk")
    organisation = _get_grant_managing_organisation()
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.MEMBER],
        organisation=organisation,
        grant=None,
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.SSO
    anonymous_client.user = user
    anonymous_client.organisation = organisation
    db_session.commit()

    yield anonymous_client


@pytest.fixture(scope="function")
def user(factories: _Factories) -> User:
    return cast(User, factories.user.create(email="accessgrantfundinguser@communities.gov.uk"))


@pytest.fixture(scope="function")
def grant_team_user(factories: _Factories, grant_recipient: GrantRecipient) -> User:
    user = cast(User, factories.user.create(email="grant_team_member@communities.gov.uk"))
    from tests.models import _get_grant_managing_organisation

    factories.user_role.create(
        user=user,
        permissions=[
            RoleEnum.MEMBER,
        ],
        organisation=_get_grant_managing_organisation(),
        grant=grant_recipient.grant,
    )
    return user


@pytest.fixture()
def platform_member_user(factories) -> User:

    user = factories.user.create(email="platform_member@communities.gov.uk")
    factories.user_role.create(user_id=user.id, user=user, permissions=[RoleEnum.MEMBER], organisation=None, grant=None)
    return user


@pytest.fixture()
def form_designer_user(factories, grant_recipient) -> User:
    from tests.models import _get_grant_managing_organisation

    user = factories.user.create(email="form_designer@communities.gov.uk")

    factories.user_role.create(
        user=user,
        permissions=[
            RoleEnum.MEMBER,
        ],
        organisation=_get_grant_managing_organisation(),
        grant=None,
    )
    return user


@pytest.fixture()
def org_admin_user(factories: _Factories) -> User:
    from tests.models import _get_grant_managing_organisation

    user = factories.user.create(email="orgadmin@communities.gov.uk")
    organisation = _get_grant_managing_organisation()
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.ADMIN],
        organisation=organisation,
        grant=None,
    )
    return user


@pytest.fixture()
def platform_admin_user(factories) -> User:

    user = factories.user.create(email="platform_admin@communities.gov.uk")
    factories.user_role.create(user_id=user.id, user=user, permissions=[RoleEnum.ADMIN], organisation=None, grant=None)
    return user


@pytest.fixture(scope="function")
def data_provider_user(factories: _Factories, grant_recipient: GrantRecipient) -> User:
    user = cast(User, factories.user.create(email="data_provider@communities.gov.uk"))
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.MEMBER, RoleEnum.DATA_PROVIDER],
        organisation=grant_recipient.organisation,
        grant=grant_recipient.grant,
    )
    return user


@pytest.fixture(scope="function")
def certifier_user(factories: _Factories, grant_recipient: GrantRecipient) -> User:
    user = cast(User, factories.user.create(email="certifier@communities.gov.uk"))
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.MEMBER, RoleEnum.CERTIFIER],
        organisation=grant_recipient.organisation,
        grant=grant_recipient.grant,
    )
    return user


@pytest.fixture(scope="function")
def grant_recipient(
    factories: _Factories,
) -> GrantRecipient:
    grant_recipient = factories.grant_recipient.create(
        organisation__can_manage_grants=False, grant__status=GrantStatusEnum.LIVE
    )
    return cast(GrantRecipient, grant_recipient)


@pytest.fixture(scope="function")
def submission_in_progress(
    factories: _Factories, db_session, grant_recipient: GrantRecipient, user: User
) -> Submission:
    question = factories.question.create(
        id=uuid.UUID("d696aebc-49d2-4170-a92f-b6ef42994294"),
        form__collection__grant=grant_recipient.grant,
        form__collection__reporting_period_start_date=date.today() - timedelta(days=30),
        form__collection__reporting_period_end_date=date.today(),
        form__collection__submission_period_end_date=date.today() + timedelta(days=30),
    )
    submission = factories.submission.create(
        grant_recipient=grant_recipient,
        collection=question.form.collection,
        mode=SubmissionModeEnum.LIVE,
        answers=[FactoryAnswer(question, TextSingleLineAnswer("Question answer"))],
    )
    submission.status = SubmissionStatusEnum.IN_PROGRESS
    db_session.commit()
    return cast(Submission, submission)


@pytest.fixture(scope="function")
def submission_ready_to_submit(
    factories: _Factories, db_session, grant_recipient: GrantRecipient, user: User
) -> Submission:
    question = factories.question.create(
        id=uuid.UUID("d696aebc-49d2-4170-a92f-b6ef42994294"),
        form__collection__grant=grant_recipient.grant,
        form__collection__reporting_period_start_date=date.today() - timedelta(days=30),
        form__collection__reporting_period_end_date=date.today(),
        form__collection__submission_period_end_date=date.today() + timedelta(days=30),
    )
    submission = factories.submission.create(
        grant_recipient=grant_recipient,
        collection=question.form.collection,
        mode=SubmissionModeEnum.LIVE,
        answers=[FactoryAnswer(question, TextSingleLineAnswer("Question answer"))],
    )
    factories.submission_event.create(
        submission=submission,
        related_entity_id=question.form.id,
        event_type=SubmissionEventType.FORM_RUNNER_FORM_COMPLETED,
        created_by=user,
        created_at_utc=datetime(2025, 11, 25, 0, 0, 0),
    )
    submission.status = SubmissionStatusEnum.READY_TO_SUBMIT
    db_session.commit()
    return cast(Submission, submission)


@pytest.fixture(scope="function")
def submission_awaiting_sign_off(
    factories: _Factories, grant_recipient: GrantRecipient, user: User, db_session
) -> Submission:
    question = factories.question.create(
        id=uuid.UUID("d696aebc-49d2-4170-a92f-b6ef42994294"),
        form__collection__grant=grant_recipient.grant,
        form__collection__submission_period_end_date=date.today() + timedelta(days=30),
        form__collection__reporting_period_start_date=date.today() - timedelta(days=60),
        form__collection__reporting_period_end_date=date.today() - timedelta(days=30),
        form__collection__status=CollectionStatusEnum.OPEN,
    )
    submission = factories.submission.create(
        grant_recipient=grant_recipient,
        collection=question.form.collection,
        mode=SubmissionModeEnum.LIVE,
        answers=[FactoryAnswer(question, TextSingleLineAnswer("Question answer"))],
    )
    factories.submission_event.create(
        submission=submission,
        related_entity_id=question.form.id,
        event_type=SubmissionEventType.FORM_RUNNER_FORM_COMPLETED,
        created_by=user,
        created_at_utc=datetime(2025, 11, 25, 0, 0, 0),
    )
    factories.submission_event.create(
        submission=submission,
        event_type=SubmissionEventType.SUBMISSION_SENT_FOR_CERTIFICATION,
        created_by=user,
        created_at_utc=datetime(2025, 11, 25, 0, 0, 1),
    )
    submission.status = SubmissionStatusEnum.AWAITING_SIGN_OFF
    db_session.commit()
    return cast(Submission, submission)


@pytest.fixture(scope="function")
def submission_submitted(factories: _Factories, db_session, grant_recipient: GrantRecipient, user: User) -> Submission:
    question = factories.question.create(
        id=uuid.UUID("d696aebc-49d2-4170-a92f-b6ef42994294"),
        form__collection__grant=grant_recipient.grant,
        form__collection__submission_period_start_date=date.today(),
        form__collection__submission_period_end_date=date.today() + timedelta(days=30),
        form__collection__reporting_period_start_date=date.today(),
        form__collection__reporting_period_end_date=date.today() + timedelta(days=30),
        form__collection__status=CollectionStatusEnum.OPEN,
    )
    submission = factories.submission.create(
        grant_recipient=grant_recipient,
        collection=question.form.collection,
        mode=SubmissionModeEnum.LIVE,
        answers=[FactoryAnswer(question, TextSingleLineAnswer("Question answer"))],
    )
    factories.submission_event.create(
        submission=submission,
        related_entity_id=question.form.id,
        event_type=SubmissionEventType.FORM_RUNNER_FORM_COMPLETED,
        created_by=user,
    )
    factories.submission_event.create(
        submission=submission,
        event_type=SubmissionEventType.SUBMISSION_SENT_FOR_CERTIFICATION,
        created_by=user,
    )
    factories.submission_event.create(
        submission=submission,
        event_type=SubmissionEventType.SUBMISSION_APPROVED_BY_CERTIFIER,
        created_by=user,
    )
    factories.submission_event.create(
        submission=submission,
        event_type=SubmissionEventType.SUBMISSION_SUBMITTED,
        created_by=user,
    )
    submission.status = SubmissionStatusEnum.SUBMITTED
    db_session.commit()

    return cast(Submission, submission)


@pytest.fixture(scope="function")
def submission_changes_requested(
    factories: _Factories, db_session, submission_submitted: Submission, grant_team_user: User
) -> Submission:
    factories.submission_event.create(
        submission=submission_submitted,
        event_type=SubmissionEventType.SUBMISSION_CHANGES_REQUESTED,
        created_by=grant_team_user,
        data=SubmissionEventHelper.event_from(
            SubmissionEventType.SUBMISSION_CHANGES_REQUESTED,
            changes_requested_reason="Please fix this",
            submission_data=submission_submitted.data_manager.data,
            section_ids=[submission_submitted.collection.forms[0].id],
        ),
    )
    factories.submission_event.create(
        submission=submission_submitted,
        event_type=SubmissionEventType.FORM_RUNNER_FORM_RESET_TO_IN_PROGRESS,
        created_by=grant_team_user,
        related_entity_id=submission_submitted.collection.forms[0].id,
    )
    helper = SubmissionHelper(submission_submitted)
    submission_submitted.status = helper._calculate_submission_status()
    db_session.commit()

    return submission_submitted


@pytest.fixture(scope="function")
def submission_ready_to_submit_with_changes_made(
    factories: _Factories, db_session, submission_changes_requested: Submission, user: User
) -> Submission:
    question = submission_changes_requested.collection.forms[0].cached_questions[0]
    submission_changes_requested.data_manager.set(question, TextSingleLineAnswer("Updated answer"))
    update_submission_data(submission_changes_requested)
    factories.submission_event.create(
        submission=submission_changes_requested,
        event_type=SubmissionEventType.FORM_RUNNER_FORM_COMPLETED,
        related_entity_id=submission_changes_requested.collection.forms[0].id,
        created_by=user,
    )
    helper = SubmissionHelper(submission_changes_requested)
    submission_changes_requested.status = helper._calculate_submission_status()
    db_session.commit()

    return submission_changes_requested


@pytest.fixture(scope="function")
def submission_awaiting_sign_off_with_changes_made(
    factories: _Factories, db_session, submission_ready_to_submit_with_changes_made: Submission, user: User
) -> Submission:
    factories.submission_event.create(
        submission=submission_ready_to_submit_with_changes_made,
        event_type=SubmissionEventType.SUBMISSION_SENT_FOR_CERTIFICATION,
        created_by=user,
    )
    helper = SubmissionHelper(submission_ready_to_submit_with_changes_made)
    submission_ready_to_submit_with_changes_made.status = helper._calculate_submission_status()
    db_session.commit()

    return submission_ready_to_submit_with_changes_made


@pytest.fixture(scope="function")
def submission_submitted_multiple_submissions(
    factories: _Factories,
    db_session,
    grant_recipient,
) -> list[Submission]:
    question = factories.question.create(
        id=uuid.UUID("d696aebc-49d2-4170-a92f-b6ef42994294"),
        data_type=QuestionDataType.TEXT_SINGLE_LINE,
        form__collection__allow_multiple_submissions=True,
        form__collection__requires_certification=False,
        form__collection__grant=grant_recipient.grant,
        form__collection__status=CollectionStatusEnum.OPEN,
    )
    collection = question.form.collection
    collection.submission_name_question_id = question.id
    db_session.flush()
    user = factories.user.create()

    grant_recipient = factories.grant_recipient.create(grant=collection.grant)
    submission_1 = factories.submission.create(
        collection=collection,
        grant_recipient=grant_recipient,
        mode=SubmissionModeEnum.LIVE,
        answers=[FactoryAnswer(question, TextSingleLineAnswer("Alpha"))],
    )
    factories.submission_event.create(
        submission=submission_1,
        related_entity_id=question.form.id,
        event_type=SubmissionEventType.FORM_RUNNER_FORM_COMPLETED,
        created_by=user,
    )
    factories.submission_event.create(
        submission=submission_1,
        event_type=SubmissionEventType.SUBMISSION_SUBMITTED,
        created_by=user,
    )
    submission_2 = factories.submission.create(
        collection=collection,
        grant_recipient=grant_recipient,
        mode=SubmissionModeEnum.LIVE,
        answers=[FactoryAnswer(question, TextSingleLineAnswer("Beta"))],
    )
    factories.submission_event.create(
        submission=submission_2,
        related_entity_id=question.form.id,
        event_type=SubmissionEventType.FORM_RUNNER_FORM_COMPLETED,
        created_by=user,
    )
    factories.submission_event.create(
        submission=submission_2,
        event_type=SubmissionEventType.SUBMISSION_SUBMITTED,
        created_by=user,
    )
    submission_1.status = SubmissionStatusEnum.SUBMITTED
    submission_2.status = SubmissionStatusEnum.SUBMITTED
    db_session.commit()

    return [submission_1, submission_2]


@pytest.fixture(scope="function")
def submission_collection_closed(factories: _Factories, grant_recipient: GrantRecipient, user: User) -> Submission:
    question = factories.question.create(
        id=uuid.UUID("d696aebc-49d2-4170-a92f-b6ef42994294"),
        form__collection__grant=grant_recipient.grant,
        form__collection__reporting_period_start_date=date.today() - timedelta(days=60),
        form__collection__reporting_period_end_date=date.today() - timedelta(days=30),
        form__collection__submission_period_end_date=date.today() - timedelta(days=30),
        form__collection__status=CollectionStatusEnum.CLOSED,
    )
    submission = factories.submission.create(
        grant_recipient=grant_recipient,
        collection=question.form.collection,
        mode=SubmissionModeEnum.LIVE,
        answers=[FactoryAnswer(question, TextSingleLineAnswer("Question answer"))],
    )
    return cast(Submission, submission)


@pytest.fixture(scope="function")
def submission_with_allow_validation(db_session, submission_submitted: Submission) -> Submission:
    collection = submission_submitted.collection
    collection.allow_validation = True
    db_session.commit()

    return submission_submitted


@pytest.fixture()
def authenticated_grant_recipient_member_client(
    anonymous_client: FundingServiceTestClient,
    factories: _Factories,
    db_session: Session,
    grant_recipient: GrantRecipient,
    user: User,
) -> Generator[FundingServiceTestClient, None, None]:
    """Create a client authenticated as a grant recipient for an org with can_manage_grants=False"""

    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.MEMBER],
        organisation=grant_recipient.organisation,
        grant=grant_recipient.grant,
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.MAGIC_LINK
    anonymous_client.user = user
    anonymous_client.grant = grant_recipient.grant
    anonymous_client.organisation = grant_recipient.organisation
    anonymous_client.grant_recipient = grant_recipient
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_grant_recipient_data_provider_client(
    anonymous_client: FundingServiceTestClient,
    factories: _Factories,
    db_session: Session,
    grant_recipient: GrantRecipient,
    user: User,
) -> Generator[FundingServiceTestClient, None, None]:
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.MEMBER, RoleEnum.DATA_PROVIDER],
        organisation=grant_recipient.organisation,
        grant=grant_recipient.grant,
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.MAGIC_LINK
    anonymous_client.user = user
    anonymous_client.grant = grant_recipient.grant
    anonymous_client.organisation = grant_recipient.organisation
    anonymous_client.grant_recipient = grant_recipient
    db_session.commit()

    yield anonymous_client


@pytest.fixture()
def authenticated_grant_recipient_certifier_client(
    anonymous_client: FundingServiceTestClient,
    factories: _Factories,
    db_session: Session,
    grant_recipient: GrantRecipient,
    user: User,
) -> Generator[FundingServiceTestClient, None, None]:
    factories.user_role.create(
        user=user,
        permissions=[RoleEnum.MEMBER, RoleEnum.CERTIFIER],
        organisation=grant_recipient.organisation,
        grant=grant_recipient.grant,
    )

    login_user(user)
    with anonymous_client.session_transaction() as session:
        session["auth"] = AuthMethodEnum.MAGIC_LINK
    anonymous_client.user = user
    anonymous_client.grant = grant_recipient.grant
    anonymous_client.organisation = grant_recipient.organisation
    anonymous_client.grant_recipient = grant_recipient
    db_session.commit()

    yield anonymous_client


def _setup_session_clean_tracking() -> None:
    # 1. When a transaction starts, assume session is clean
    @event.listens_for(Session, "after_begin")
    def after_begin(session: Session, transaction: SessionTransaction, connection: Connection) -> None:
        session.info["clean"] = True

    # 2. Before flush — catch dirty state even if flush hasn't happened yet
    @event.listens_for(Session, "before_flush")
    def before_flush(session: Session, flush_context: Any, instances: object | None) -> None:
        if session.new or session.dirty or session.deleted:
            session.info["clean"] = False

    # 3. Before commit — set false since session has changes and did not flush
    @event.listens_for(Session, "before_commit")
    def before_commit(session: Session) -> None:
        if session.new or session.dirty or session.deleted:
            session.info["clean"] = False

    # 4. After commit — reset clean flag
    @event.listens_for(Session, "after_commit")
    def after_commit(session: Session) -> None:
        session.info["clean"] = True

    # 5. After rollback — reset clean flag
    @event.listens_for(Session, "after_rollback")
    def after_rollback(session: Session) -> None:
        session.info["clean"] = True


@contextmanager
def _count_sqlalchemy_queries() -> Generator[list[QueryInfo], None, None]:
    queries: list[QueryInfo] = []
    num_existing_queries = len(get_recorded_queries())

    yield queries

    new_queries = get_recorded_queries()
    queries.extend(new_queries[num_existing_queries:])


@pytest.fixture
def track_sql_queries() -> t.Callable[[], _GeneratorContextManager[list[QueryInfo], None, None]]:
    return _count_sqlalchemy_queries


@pytest.fixture
def mock_sentry_metrics(mocker) -> Generator[Any, Any, None]:
    emit_metric_mock = mocker.patch("app.metrics.metrics.count")
    yield emit_metric_mock
