"""``configure_env`` — the load-bearing alembic env.py helper.

Reads the URL + metadata + options from the consumer; runs the
migration via either the sync or async path based on the URL's
driver prefix.

Async drivers detected by URL prefix:

  - ``postgresql+asyncpg://``
  - ``postgresql+psycopg://`` (psycopg 3 with asyncio mode)
  - ``mysql+aiomysql://``
  - ``mysql+asyncmy://``
  - ``sqlite+aiosqlite://``

Other URLs (``postgresql://``, ``postgresql+psycopg2://``,
``mysql+pymysql://``, ``sqlite:///``) take the sync path.
"""

from __future__ import annotations

import asyncio
import logging
import sys
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any

from mgf.common.exceptions import OperationError
from sqlalchemy import pool
from sqlalchemy.engine.url import make_url

_LOGGER = logging.getLogger("mgf.alembic")

if TYPE_CHECKING:
    from collections.abc import Callable, Iterator

    from sqlalchemy import Connection, MetaData

    IncludeObjectFn = Callable[[Any, str, str, bool, Any], bool]


def _safe_url(url: str) -> str:
    """Return ``url`` with the password masked, for safe logging.

    Uses SQLAlchemy's :func:`make_url` to parse + re-render with
    ``hide_password=True``. A malformed URL returns the literal
    string ``"<malformed-url>"`` rather than raising — this helper
    is called from error paths where re-raising a parse error
    would mask the real failure.

    Used internally to keep DB credentials out of any exception
    message or log record this helper emits. (SH-01.)
    """
    try:
        return make_url(url).render_as_string(hide_password=True)
    except Exception:
        return "<malformed-url>"


@contextmanager
def _safe_error_context(url: str) -> Iterator[None]:
    """Re-raise exceptions with any occurrence of ``url`` masked.

    SQLAlchemy / DB-driver errors occasionally include the
    connection URL — and thus the embedded password — verbatim
    in the exception message. This wrapper substitutes the
    masked form in every ``str`` element of ``exc.args`` before
    re-raising. The exception **type** is preserved; callers
    catching ``OperationalError`` still see ``OperationalError``.

    Silent-by-design: when the URL doesn't appear in the message
    (the common case for most failures), nothing changes.
    """
    safe = _safe_url(url)
    try:
        yield
    except Exception as exc:
        if url == safe:
            raise
        try:
            new_args = tuple(
                arg.replace(url, safe) if isinstance(arg, str) else arg for arg in exc.args
            )
            if new_args != exc.args:
                exc.args = new_args
        except Exception:  # noqa: S110 — never break exception propagation
            pass
        raise


# URL prefixes that indicate an async driver. Conservative list —
# adding a driver not here means it falls through to the sync path
# (which will raise at runtime if the driver doesn't have a sync
# variant — which is the right behavior; users will discover it
# immediately).
_ASYNC_URL_PREFIXES: tuple[str, ...] = (
    "postgresql+asyncpg://",
    "postgresql+psycopg://",
    "mysql+aiomysql://",
    "mysql+asyncmy://",
    "sqlite+aiosqlite://",
)


def configure_env(
    *,
    database_url: str,
    target_metadata: MetaData,
    compare_type: bool = True,
    compare_server_default: bool = True,
    naming_convention: dict[str, str] | None = None,
    include_object: IncludeObjectFn | None = None,
    include_schemas: bool = False,
    is_async: bool | None = None,
    transaction_per_migration: bool = False,
) -> None:
    """Run alembic migrations using ``database_url`` + ``target_metadata``.

    Call from your ``alembic/env.py`` at module-level scope.
    Replaces the ~40-line boilerplate the standard async env.py
    template ships.

    Parameters
    ----------
    database_url
        SQLAlchemy URL. Async drivers (``+asyncpg``, ``+aiomysql``,
        ``+aiosqlite``, ``+psycopg`` async, ``+asyncmy``) are
        auto-detected and run via :func:`asyncio.run`. Other URLs
        take the synchronous path.
    target_metadata
        ``MetaData`` from your declarative base
        (e.g. ``Base.metadata``). Required for autogenerate to know
        the model schema.
    compare_type
        Pass to ``alembic.context.configure``. ``True`` (default)
        means autogenerate notices column-type changes; ``False``
        means it ignores them. Most consumers want ``True``.
    compare_server_default
        Pass to ``alembic.context.configure``. ``True`` (default)
        means autogenerate notices server-default changes; ``False``
        means it ignores them. Most consumers want ``True``.
    naming_convention
        Optional naming convention for auto-generated constraints
        (indexes, FK, PK). Recommended pattern::

            naming_convention = {
                "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",
            }

        Without this, alembic generates anonymous constraint names
        that vary across DBs and produce noisy migration diffs.

        **Side effect (RA-02):** when supplied, this kwarg MUTATES
        the passed ``target_metadata`` — the helper does
        ``target_metadata.naming_convention = naming_convention``
        before alembic's autogenerate runs. The mutation persists
        for the lifetime of the metadata object. The common consumer
        pattern (``configure_env(target_metadata=Base.metadata,
        naming_convention=...)``) sets the convention on
        ``Base.metadata`` permanently for the process — fine when
        ``env.py`` is invoked from the alembic CLI (one-shot), but
        relevant if you ever invoke ``configure_env`` from a process
        that ALSO does runtime model work against the same
        ``Base.metadata``. To avoid the persisted mutation, pass
        ``copy.copy(Base.metadata)`` instead.
    include_object
        Optional ``(object, name, type_, reflected, compare_to) -> bool``
        callable forwarded to :func:`alembic.context.configure`.
        Returning ``False`` excludes the object from autogenerate
        revisions. Canonical use case: filter Postgres extension
        tables (``spatial_ref_sys`` from PostGIS, ``_timescaledb_*``
        from TimescaleDB, etc.) so ``alembic revision --autogenerate``
        doesn't propose phantom drops::

            def _exclude_extensions(obj, name, type_, reflected, compare_to):
                return not (type_ == "table" and name in {"spatial_ref_sys"})

            configure_env(..., include_object=_exclude_extensions)

        Default ``None`` includes everything, matching alembic's own
        default. (PAPER-22.)

        **Trust contract (SH-04):** the callable runs inside the
        migration process with full Python access. It SHOULD be
        pure (no side effects), deterministic (same input → same
        output), and idempotent across autogenerate runs. Errors
        raised propagate to alembic; the migration may abort
        partially. Don't use this seam for credential / vault
        lookups, network calls, or anything that can fail —
        per-object I/O also amplifies the cost Nx (PC-03).

        **Performance (PC-03):** the callable runs once per
        schema object during autogenerate. For a schema with N
        objects and callable time T, total autogenerate time is
        ~NxT plus alembic's metadata-diff work (~100 µs per
        object). Keep callbacks fast (≤10 µs); avoid I/O /
        database queries from inside the callback.
    include_schemas
        Pass to :func:`alembic.context.configure`. ``True`` lets
        autogenerate see objects in non-default schemas. Default
        ``False`` matches alembic's default. (PAPER-22.)
    is_async
        Override the URL-prefix heuristic for async-vs-sync
        routing. ``None`` (default) uses the heuristic
        (:data:`_ASYNC_URL_PREFIXES` match). Pass ``True`` to
        force the async path for a cutting-edge driver not on
        the prefix list; pass ``False`` to force the sync path
        for the dual-mode case (``postgresql+psycopg://``
        with psycopg 3 in sync mode). (RA-03 / RA-04.)
    transaction_per_migration
        Pass ``True`` to forward
        ``transaction_per_migration=True`` to
        :func:`alembic.context.configure` — each migration runs
        in its own transaction. MySQL consumers SHOULD use this:
        MySQL's DDL auto-commits, so the default wrap-all-in-
        one-transaction shape is meaningless and the per-
        migration transaction matches MySQL's actual semantics.
        Postgres consumers can leave this ``False`` (default) —
        transactional DDL means a multi-step migration that
        fails halfway leaves the schema unchanged. (RA-05.)

    Notes
    -----
    **Security — offline mode + credential-bearing DDL.** When
    alembic is invoked with ``--sql`` (offline mode), the generated
    migration SQL is emitted to stdout. Some consumer-authored
    migrations contain literal credentials —
    ``CREATE USER 'tenant_user' WITH PASSWORD '<secret>';``,
    ``ALTER ROLE ... ENCRYPTED PASSWORD '<rotated>';``, etc. — and
    the offline output is the typical "review with the DBA" path,
    which means humans read it. This helper emits a stderr warning
    on offline-mode entry so operators piping the output to a file
    don't accidentally land credentials in a terminal scrollback or
    a shared review channel. The right mitigation is consumer-side:
    use Postgres's ``\\set`` mechanism or a templated migration
    system that injects credentials at upgrade time, not at
    migration-author time. (SH-02.)

    **Security — DB URL redaction.** Connection failures surfacing
    from SQLAlchemy / driver layers occasionally include the raw
    URL — and thus its embedded password — in the exception
    message. This helper substitutes a password-masked form before
    re-raising; callers catching ``OperationalError`` still see
    ``OperationalError``, just with the credentials stripped from
    ``exc.args``. (SH-01.)

    **Performance — async path:** when ``database_url`` matches one
    of the async prefixes, this helper calls :func:`asyncio.run`,
    which allocates and tears down a fresh event loop on every
    invocation (~5-20 ms on CPython). For a single migration run,
    that cost is amortised over the DDL execution and is invisible.
    For test fixtures that invoke ``configure_env`` repeatedly
    (e.g. a project's pytest suite running hundreds of small
    migrations in series), the per-call loop setup compounds —
    100 invocations costs ~1 s of pure loop-creation time. The
    public async entry-point :func:`run_async_migrations` is the
    workaround: it skips ``asyncio.run`` so embedded callers
    await it directly. (PC-02 / RA-01.)

    **Performance — offline-mode SQL throughput (PC-04):**
    ``alembic upgrade --sql`` emits SQL for ALL pending migrations
    in series. For a project with 100+ migrations, generation
    takes seconds (~1-5 s for moderate-size projects). This is
    alembic's own iteration cost; the wrapper adds no measurable
    overhead. No mitigation — operator awareness only.

    **Performance — engine construction (PC-05):** both online
    paths construct a SQLAlchemy engine with ``poolclass=NullPool``
    (~50-200 µs per construction). NullPool is correct for
    one-shot migrations: every operation gets a fresh connection,
    no pool overhead, no leaked connections between migration
    invocations. Document the choice; consumers wanting a long-
    lived connection should NOT reach for this helper — it's
    explicitly for the migration-as-one-shot shape.

    **Audit (SH-03):** migration runs go through standard alembic
    stdout output; this wrapper does NOT add structured INFO
    records with ``audit=true`` per LG-04. Consumers wanting a
    SIEM-grepable audit trail can attach a logging handler to
    the ``alembic.runtime.migration`` logger; that surface
    captures the revision-from / revision-to pair. Adding an
    explicit ``audit=true`` emission inside this helper is
    tracked for a follow-up (SH-03 in the SECURITY_HARDENING
    tracker) but doesn't block v1.0 — alembic's own output is
    operator-readable.
    """
    from alembic import context

    if naming_convention is not None:
        target_metadata.naming_convention = naming_convention

    if context.is_offline_mode():
        # SH-02: surface a stderr warning so operators piping the
        # SQL output (e.g. `alembic upgrade --sql > migrate.sql`)
        # know that credential-bearing DDL — `CREATE USER ... WITH
        # PASSWORD '<secret>'`, `ALTER ROLE ... ENCRYPTED PASSWORD
        # '<rotated>'`, etc. — will be written verbatim to the
        # output channel. The library can't scan migration SQL
        # (we hand off to alembic); the right mitigation is on
        # the consumer side (use Postgres `\set` or a templated
        # migration system that injects credentials at upgrade
        # time, not at migration-author time).
        sys.stderr.write(
            "mgf.alembic: running in offline mode (--sql). Generated "
            "SQL is emitted to stdout; if your migrations include "
            "password / secret literals, ensure the output channel "
            "is appropriate. See SH-02 in the tracker for mitigation "
            "options.\n"
        )
        _run_offline(
            url=database_url,
            target_metadata=target_metadata,
            compare_type=compare_type,
            compare_server_default=compare_server_default,
            include_object=include_object,
            include_schemas=include_schemas,
            transaction_per_migration=transaction_per_migration,
        )
        return

    # RA-03 / RA-04: the heuristic detects async drivers by URL prefix.
    # That's correct for the documented drivers, but `postgresql+psycopg`
    # is dual-mode (psycopg 3 supports both sync + async under the
    # same URL prefix). Allow `is_async=` to override the heuristic
    # — consumers using psycopg 3 in sync mode pass `is_async=False`;
    # consumers using a brand-new async driver not on the
    # `_ASYNC_URL_PREFIXES` list pass `is_async=True`. None (default)
    # falls back to the heuristic.
    if is_async is None:
        is_async = database_url.startswith(_ASYNC_URL_PREFIXES)
    if is_async:
        # RA-01: `asyncio.run(...)` raises `RuntimeError("cannot be
        # called from a running event loop")` if a loop is already
        # active in this thread. The standard alembic-CLI shape has
        # no loop active (works correctly), but embedded callers —
        # pytest-asyncio fixtures running migrations inline, a long-
        # running test harness, a Notebook context — fail with a
        # cryptic message. Detect the running-loop case + raise a
        # typed error that names the supported alternative
        # (`await run_async_migrations(...)`).
        try:
            asyncio.get_running_loop()
        except RuntimeError:
            # No loop running — the standard CLI case. Safe to use
            # asyncio.run().
            pass
        else:
            raise OperationError(  # EH-01: typed mgf-common exception (was bare RuntimeError)
                "configure_env: cannot run async migrations from a "
                "running event loop. Either invoke from a sync "
                "context (alembic CLI, plain `python` script), OR "
                "use the public async primitive directly:\n"
                "    from mgf.alembic import run_async_migrations\n"
                "    await run_async_migrations(\n"
                "        url=..., target_metadata=..., ...\n"
                "    )\n"
                "(See RA-01 in mgf-alembic's tracker for context.)"
            )
        asyncio.run(
            _run_online_async(
                url=database_url,
                target_metadata=target_metadata,
                compare_type=compare_type,
                compare_server_default=compare_server_default,
                include_object=include_object,
                include_schemas=include_schemas,
                transaction_per_migration=transaction_per_migration,
            )
        )
    else:
        _run_online_sync(
            url=database_url,
            target_metadata=target_metadata,
            compare_type=compare_type,
            compare_server_default=compare_server_default,
            include_object=include_object,
            include_schemas=include_schemas,
            transaction_per_migration=transaction_per_migration,
        )


def _run_offline(
    *,
    url: str,
    target_metadata: MetaData,
    compare_type: bool,
    compare_server_default: bool,
    include_object: IncludeObjectFn | None,
    include_schemas: bool,
    transaction_per_migration: bool = False,
) -> None:
    """Offline mode — emit SQL without connecting to the database.

    Used for ``alembic upgrade --sql ...`` that pipes migration SQL
    to a file or to a DBA. No connection is opened.
    """
    from alembic import context

    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
        compare_type=compare_type,
        compare_server_default=compare_server_default,
        # alembic types include_object's signature with Literal type_
        # values + SchemaItem — narrower than our public Any-shaped
        # alias. The alias is intentionally permissive (consumers
        # writing the callable shouldn't import alembic types just
        # to satisfy mypy); the runtime contract is alembic's.
        include_object=include_object,  # type: ignore[arg-type]
        include_schemas=include_schemas,
        transaction_per_migration=transaction_per_migration,
    )
    with context.begin_transaction():
        context.run_migrations()


def _do_run_migrations(
    connection: Connection,
    *,
    target_metadata: MetaData,
    compare_type: bool,
    compare_server_default: bool,
    include_object: IncludeObjectFn | None,
    include_schemas: bool,
    transaction_per_migration: bool = False,
) -> None:
    """Run migrations against an open SYNC connection.

    Called by both the sync and async online paths. The async path
    arrives here via ``await connection.run_sync(...)``.
    """
    from alembic import context

    context.configure(
        connection=connection,
        target_metadata=target_metadata,
        compare_type=compare_type,
        compare_server_default=compare_server_default,
        # See note in _run_offline above.
        include_object=include_object,  # type: ignore[arg-type]
        include_schemas=include_schemas,
        transaction_per_migration=transaction_per_migration,
    )
    with context.begin_transaction():
        context.run_migrations()


def _run_online_sync(
    *,
    url: str,
    target_metadata: MetaData,
    compare_type: bool,
    compare_server_default: bool,
    include_object: IncludeObjectFn | None,
    include_schemas: bool,
    transaction_per_migration: bool = False,
) -> None:
    """Online sync mode — open one connection, run migrations, dispose."""
    from sqlalchemy import create_engine

    # SH-01: cover engine construction (URL-parse errors) too — not just
    # connect — so a malformed password-bearing URL can't leak via the
    # create_engine exception.
    _LOGGER.info(
        "alembic migration run starting (online-sync)",
        extra={  # LG-11 operation start + SC-17 audit (schema-changing action)
            "operation": "alembic.migrate",
            "event": "mgf.alembic.migration_run",
            "mode": "online-sync",
            "url": _safe_url(url),
            "audit": True,
        },
    )
    with _safe_error_context(url):
        connectable = create_engine(url, poolclass=pool.NullPool)
        try:
            with connectable.connect() as connection:
                _do_run_migrations(
                    connection,
                    target_metadata=target_metadata,
                    compare_type=compare_type,
                    compare_server_default=compare_server_default,
                    include_object=include_object,
                    include_schemas=include_schemas,
                    transaction_per_migration=transaction_per_migration,
                )
            _LOGGER.info(
                "alembic migration run complete (online-sync)",
                extra={
                    "operation": "alembic.migrate",
                    "event": "mgf.alembic.migration_complete",
                    "mode": "online-sync",
                    "url": _safe_url(url),
                },
            )
        finally:
            connectable.dispose()


async def _run_online_async(
    *,
    url: str,
    target_metadata: MetaData,
    compare_type: bool,
    compare_server_default: bool,
    include_object: IncludeObjectFn | None,
    include_schemas: bool,
    transaction_per_migration: bool = False,
) -> None:
    """Online async mode — async engine + ``run_sync`` for the alembic call."""
    from sqlalchemy.ext.asyncio import create_async_engine

    # SH-01: cover engine construction (URL-parse errors) too.
    _LOGGER.info(
        "alembic migration run starting (online-async)",
        extra={  # LG-11 operation start + SC-17 audit (schema-changing action)
            "operation": "alembic.migrate",
            "event": "mgf.alembic.migration_run",
            "mode": "online-async",
            "url": _safe_url(url),
            "audit": True,
        },
    )
    with _safe_error_context(url):
        connectable = create_async_engine(url, poolclass=pool.NullPool)
        try:
            async with connectable.connect() as connection:
                await connection.run_sync(
                    _do_run_migrations,
                    target_metadata=target_metadata,
                    compare_type=compare_type,
                    compare_server_default=compare_server_default,
                    include_object=include_object,
                    include_schemas=include_schemas,
                    transaction_per_migration=transaction_per_migration,
                )
            _LOGGER.info(
                "alembic migration run complete (online-async)",
                extra={
                    "operation": "alembic.migrate",
                    "event": "mgf.alembic.migration_complete",
                    "mode": "online-async",
                    "url": _safe_url(url),
                },
            )
        finally:
            await connectable.dispose()


async def run_async_migrations(
    *,
    url: str,
    target_metadata: MetaData,
    compare_type: bool = True,
    compare_server_default: bool = True,
    include_object: IncludeObjectFn | None = None,
    include_schemas: bool = False,
    transaction_per_migration: bool = False,
) -> None:
    """Public async entry-point — run migrations from inside an event loop.

    Equivalent to ``configure_env(...)`` but **awaitable**: callers
    already running inside an event loop (pytest-asyncio fixtures,
    embedded test harnesses, notebooks) use this directly instead of
    going through ``configure_env``'s ``asyncio.run`` shape, which
    raises ``RuntimeError("cannot be called from a running event
    loop")``.

    Same defaults as :func:`configure_env`'s async path. Only the
    online-async shape is exposed here — there's no useful "offline
    mode async" shape because offline mode never opens a connection.
    For sync URLs or offline mode, call :func:`configure_env` from a
    sync context.

    Pairs with :func:`configure_env`'s RA-01 running-loop guard:
    when the guard raises, this is the function the error message
    points at.
    """
    await _run_online_async(
        url=url,
        target_metadata=target_metadata,
        compare_type=compare_type,
        compare_server_default=compare_server_default,
        include_object=include_object,
        include_schemas=include_schemas,
        transaction_per_migration=transaction_per_migration,
    )


__all__ = ["configure_env", "run_async_migrations"]
