from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
import sys
import os
import sqlalchemy.exc

from config import get_settings
from db.bootstrap import initialise_database
from api.routes import router
from api.sector_routes import router as sector_router
from api.market_analysis_routes import router as ma_router
# V2 runtime — Stage 5 cutover: v2 API routes replace legacy scanner +
# auto_paper routes; PositionManager replaces the legacy PositionMonitor
# daemon; PaperExecutor replaces scalper.engine.auto_enter_trade.
from api.v2_bootstrap import init_v2_runtime
from api.v2_config_bootstrap import init_v2_config
from api.v2_routes import router as v2_router
from api.v2_market_intelligence_routes import router as v2_market_intel_router
from api.ai_auto_scan_routes import router as ai_auto_scan_router
from api.intelligence_routes import router as intelligence_router
from api.operations_routes import router as operations_router
from api.operations_dashboard_routes import router as operations_console_router
from api.cli_operations_routes import router as cli_operations_router
from api.capture_scheduler_routes import router as capture_scheduler_router
from api.shadow_console_routes import router as shadow_console_router
# The API observation middleware. `main.py` is the only production module
# that imports `operations` at all (see install_operations() in the lifespan
# below), and this is a second import in that same file rather than a new
# dependency edge: `operations.api_health` imports nothing but the standard
# library, so loading it here opens no database connection and boots nothing.
from operations.api_health import ApiObservationMiddleware
from api.auth_routes import router as auth_v1_router
from api.user_routes import router as user_v1_router
from api.broker_mgmt_routes import router as broker_v1_router
from api.dashboard_routes import router as dashboard_router
from api.subscription_routes import router as subscription_router
from api.contact_routes import router as contact_router
from api.announcement_routes import router as announcement_public_router
from admin import admin_router, bootstrap_super_admin
from replay.dashboard_routes import router as research_dashboard_router
from replay.routes import router as replay_router
from security import SecurityMiddleware, get_store  # noqa: F401  # eager Redis probe

settings = get_settings()

# ── Logging setup ─────────────────────────────────────────────────────
logger.remove()
logger.add(
    sys.stdout,
    level=settings.log_level,
    format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level}</level> | {message}",
)
logger.add(
    "logs/algo_trading.log",
    rotation="1 day",
    retention="30 days",
    level="INFO",
)

# ── V2 runtime cutover (Stage 5): legacy scheduler + PositionMonitor
# daemons are removed. PaperExecutor + PositionManager are singletons
# initialised in the lifespan startup.


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Startup and shutdown logic."""
    # Startup
    logger.info(f"Starting algo trading backend ({settings.app_env})")

    # Broker HTTP forensics, installed before anything can issue a broker
    # request — the database bootstrap below opens no HTTP connection, but the
    # broker-session restoration further down does, and an instrument that
    # misses the first requests of a process is an instrument with a gap in it.
    #
    # It wraps `requests.adapters.HTTPAdapter.send` at the class, records
    # Angel One round-trips only, and passes every other host straight through.
    # Observation, never intervention: no limit, cadence, retry or header is
    # touched. See `brokers/http_forensics.py`.
    if settings.broker_http_forensics:
        from brokers import http_forensics
        http_forensics.install()

    # The identity/transport experiment, installed **after** the forensics so
    # it is the outer layer: the `pooled` arm substitutes the adapter, and
    # forensics must observe the connection that actually carried the request
    # rather than the one it was going to be sent on.
    #
    # Installed only when an operator has opted in, so a platform that is not
    # running the experiment carries none of it. The gate is the *mode*, not
    # the arm: `schedule` installs once for the whole session and rotates
    # through `off` inside it, so the control blocks are structurally
    # identical to the treated ones. A wrapper that appeared and disappeared
    # at block boundaries would be a second variable in an experiment that
    # permits one.
    from brokers import identity_experiment
    if identity_experiment.mode() != identity_experiment.ARM_OFF:
        identity_experiment.install()

    try:
        # One ordered plan — schema migrations, then every create-and-seed
        # initialiser, then the registry consistency pass. It lives in
        # `db/bootstrap.py` and `scripts/ci/bootstrap_ci_db.py` calls the same
        # function, which is what makes CI and production provably identical
        # here rather than identical by whoever last edited both lists
        # (Review finding H-5).
        report = initialise_database()
        logger.info(f"Database ready — {report.summary()}")
        # Warm up Redis / in-memory store detection so first request is fast.
        get_store()
        # Optional auto-seed of the first Super Admin (env-driven).
        bootstrap_super_admin()
    except sqlalchemy.exc.OperationalError as e:
        host = settings.db_host
        port = settings.db_port
        db   = settings.db_name
        logger.error(
            f"Cannot connect to MySQL at {host}:{port}/{db}. "
            "Is the database server running? Check your .env settings."
        )
        logger.debug(f"SQLAlchemy detail: {e}")
        os._exit(1)
    # Restore the most recently connected default broker session (best-effort).
    #
    # Three conditions, and only the first was here before: the session must
    # say `connected`, it must still hold an auth token, and that token must
    # not have expired. A disconnect now clears the tokens as well as the
    # status (`api/broker_mgmt_routes.disconnect_broker`), so the second
    # condition is what makes restoration safe against a row edited by hand —
    # and `DefaultBrokerResolver.resolve` refuses a non-`connected` session
    # outright, so this query is a filter rather than the guarantee.
    #
    # Restoring an expired session used to leave the process holding an SDK
    # that reported itself authenticated and failed on the first real call,
    # which reads as a broker outage rather than as a session to reconnect.
    #
    # "Has it expired?" is `api.user_broker_status.is_token_expired` — the same
    # sentence the Brokers page and `get_active_broker` ask. Boot used to spell
    # it `<` with its own naive-to-UTC normalisation, so a token expiring on the
    # instant would be restored here and reported EXPIRED on the page.
    try:
        from api.user_broker_status import as_utc, is_token_expired
        from db.models import SessionLocal
        from db.auth_models import BrokerConnection, BrokerSession
        from brokers.resolver import DefaultBrokerResolver
        _db = SessionLocal()
        try:
            latest_session = (
                _db.query(BrokerSession)
                .join(BrokerConnection, BrokerSession.broker_connection_id == BrokerConnection.id)
                .filter(
                    BrokerSession.status == "connected",
                    BrokerSession.auth_token.isnot(None),
                    BrokerConnection.is_default == True,   # noqa: E712
                    BrokerConnection.is_active == True,    # noqa: E712
                )
                .order_by(BrokerSession.last_connected_at.desc())
                .first()
            )
            if latest_session and is_token_expired(latest_session.expires_at):
                logger.info(
                    f"Broker session {latest_session.id} expired at "
                    f"{as_utc(latest_session.expires_at).isoformat()} — not "
                    f"restored; the user must reconnect from the Brokers page"
                )
                latest_session = None
            if latest_session:
                conn = _db.query(BrokerConnection).filter(
                    BrokerConnection.id == latest_session.broker_connection_id
                ).first()
                DefaultBrokerResolver.resolve(conn, latest_session)
                logger.info(
                    f"Broker session restored on startup: {conn.broker_name} "
                    f"(user_id={conn.user_id}, client={conn.display_name or 'unknown'})"
                )
            else:
                logger.info("No active broker session found in DB — broker must be connected manually")
        finally:
            _db.close()
    except Exception as e:
        logger.warning(f"Could not restore broker session on startup: {e}")

    # Ensure the exchange_holidays table has data for the current year on
    # first boot. In steady state the monthly scheduler keeps this fresh;
    # this only fires on a fresh install (or when the table's been wiped)
    # so the first request doesn't hit an empty holiday list.
    try:
        from datetime import datetime as _dt, timezone as _tz, timedelta as _td
        from db.models import SessionLocal as _SessionLocal
        from services.holiday_service import HolidayService as _HS
        _year = _dt.now(_tz(_td(hours=5, minutes=30))).year
        with _SessionLocal() as _db:
            if len(_HS.get_year(_db, _year)) == 0:
                logger.info(f"NSE holiday table empty for {_year} — running first-time sync")
                _stats = _HS.sync_year(_db, _year)
                logger.info(f"NSE holiday first-time sync: {_stats}")
            else:
                logger.info(f"NSE holiday table already populated for {_year}")
    except Exception as e:  # noqa: BLE001
        logger.warning(f"NSE holiday startup check failed: {e!r}")

    # V2 configuration (Review finding H-4). Loads the ratified Spec 05 key
    # set through the full validation pipeline, records the snapshot in
    # `config_snapshots`, and gives the observability logger a real
    # `config_version` instead of the "cfg-unset" placeholder it has carried
    # since Milestone 1.3. Runs BEFORE `init_v2_runtime()` so the first event
    # the engine can emit already names its configuration.
    #
    # A validation failure is deliberately fatal: a platform that cannot
    # construct its own ratified configuration must not trade on a guess.
    init_v2_config()

    # V2 runtime bootstrap (Stage 5 cutover). Replaces the legacy
    # scheduler + position_monitor daemons. PaperExecutor becomes the
    # sole execution engine; PositionManager becomes the sole owner of
    # paper positions.
    init_v2_runtime()
    logger.info("V2 runtime initialised (PaperExecutor + PositionManager)")

    # Phase 15 — Operations. Installed AFTER init_v2_runtime() so the
    # singletons it observes exist, and BEFORE the scheduler starts so the
    # first tick and the first adoption are recorded.
    #
    # This is the ONE place in production that imports `operations`. The
    # engine never does; `scripts/ci/check_operations_isolation.sh` fails the
    # build if it ever tries. Operations subscribes to `api/v2_observers`,
    # which is a no-op with no subscribers — so a failure here costs
    # observability and nothing else.
    try:
        from operations.runtime import install_operations
        install_operations()
    except Exception as e:  # noqa: BLE001
        logger.warning(f"Operations layer failed to install: {e!r}")

    # Phase 10: the position lifecycle needs a driver in production. Until
    # now `PositionManager.tick()` had one caller — the historical replay —
    # so live paper positions were opened and then never managed. The
    # scheduler supplies the wall clock and the prices; every exit decision
    # remains inside `v2/positions/`.
    try:
        from api.v2_scheduler import start_tick_scheduler
        start_tick_scheduler()
    except Exception as e:  # noqa: BLE001
        logger.warning(f"V2 tick scheduler failed to start: {e!r}")

    # AI Auto Scan (2026-08-21). The second loop, and a strictly narrower one:
    # it produces signals and hands them to Paper Admission, and never touches
    # a position. Started AFTER the tick scheduler because a position it opens
    # must have a manager to be adopted by — the ordering is not load-bearing
    # within a process (the tick loop adopts from the repository on every tick,
    # so it finds anything opened before it started), but starting the producer
    # after the consumer is the honest order to read.
    #
    # A failure costs the feature and not the boot: a scan nobody can start is
    # recoverable, an app that will not start is not.
    try:
        from ai_auto_scan.scheduler import start_scheduler as start_auto_scan
        start_auto_scan()
    except Exception as e:  # noqa: BLE001
        logger.warning(f"AI Auto Scan scheduler failed to start: {e!r}")

    yield

    # Stop the producer before the manager, so no cycle can open a position
    # into a runtime that is shutting down. Running scans stay RUNNING and are
    # resumed on the next boot — a deploy is not a decision about a user's
    # trading, and `next_run_at` is a column precisely so the cadence survives.
    try:
        from ai_auto_scan.scheduler import stop_scheduler as stop_auto_scan
        await stop_auto_scan()
    except Exception as e:  # noqa: BLE001
        logger.warning(f"AI Auto Scan scheduler shutdown error: {e!r}")

    # Shutdown — stop the tick scheduler, then let the v2 singletons go.
    # V2 Migration Stage 6 (2026-07-28): backtest + backtest_v2 systems
    # retired to backend/retired_files/; shutdown hook removed.
    try:
        from api.v2_scheduler import stop_tick_scheduler
        await stop_tick_scheduler()
    except Exception as e:  # noqa: BLE001
        logger.warning(f"V2 tick scheduler shutdown error: {e!r}")
    # Drain any manager events the last tick produced, then record the
    # shutdown. Runs after the scheduler has stopped so nothing is still
    # appending to the logs being drained.
    try:
        from operations.runtime import shutdown_operations
        shutdown_operations()
    except Exception as e:  # noqa: BLE001
        logger.warning(f"Operations shutdown error: {e!r}")
    logger.info("V2 runtime shutdown")
    # The broker-call census, last: every loop above has stopped, so nothing
    # can still be issuing requests and the block describes a closed session
    # rather than a moving one. `BROKER_RATE` lines are emitted by arrivals and
    # so cannot report the final partial minute — this is what closes it.
    if settings.broker_http_forensics:
        try:
            from brokers import http_forensics
            http_forensics.log_summary(reason="shutdown")
        except Exception as e:  # noqa: BLE001
            logger.warning(f"Broker census summary error: {e!r}")
    logger.info("Backend shutdown complete")


app = FastAPI(
    title="Algo Trading Backend",
    description="Semi-auto trading system for NSE F&O with multi-broker support",
    version="1.0.0",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",   # user app (Vite dev)
        "http://localhost:3000",
        "http://localhost:5174",   # admin app (Vite dev, separate port)
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Security layer ────────────────────────────────────────────────────
# Runs BEFORE any route handler. Every rejection is logged to `security_events`.
# All thresholds live in the `security_config` table (edit rows → effect is
# immediate, no redeploy). Loopback is exempt in non-production environments.
app.add_middleware(SecurityMiddleware)

# ── API observation (Operations Control Center §3) ────────────────────
# A passive ASGI middleware that records the method, matched route, status
# code and duration of every request that completes. It is the ONLY way the
# console can report API health honestly: the alternative — probing endpoints
# to see if they answer — would mean the dashboard calling /api/v2/analyse,
# which runs the signal engine and issues broker requests. An observer that
# generates the traffic it measures is a participant, and Phase 15 Rule 2
# forbids Operations from participating.
#
# Added AFTER SecurityMiddleware in source order, which in Starlette means it
# runs INSIDE it: a request rejected by the security layer never reaches this
# middleware and is not counted. That is deliberate — this measures the
# application's own serving behaviour, not the security layer's.
#
# It forwards `send` unchanged, holds no lock and swallows every exception it
# could raise. It cannot alter, delay or fail a response.
app.add_middleware(ApiObservationMiddleware)

app.include_router(router,           prefix="/api")
# V2 runtime routes — the sole signal-generation + paper-execution
# surface post-Stage-5. Legacy scanner_router + auto_paper_router
# unmounted at Stage 5 cutover.
app.include_router(v2_router,        prefix="/api")
# V2 Market Intelligence (2026-08-05) — the market context a trader reads
# BEFORE running the scanner. Shares the /api/v2 prefix because it is built on
# the V2 frame, but is a separate router because it is not part of the runtime:
# it produces no signal, opens no position and spends no scan credit. The AI
# Scanner does not call it, and it does not call the AI Scanner.
#
# Not to be confused with `intelligence_router` below, which is the Phase 14
# Super Admin engine-observation surface under /api/intelligence/*. Different
# audience, different payload, different guard.
app.include_router(v2_market_intel_router, prefix="/api")
# AI Auto Scan (2026-08-21) — the unattended twin of the Index Scanner. A user
# starts a scan on one index and a background loop analyses it on the market
# clock, admitting the first qualifying signal to paper trading.
#
# Mounted outside /api/v2/* on purpose. It is a *client* of the V2 engine, not
# part of it: every analysis goes through `v2_routes.run_analysis` and every
# position through `PaperExecutor`, so the runtime surface stays exactly as
# wide as it was. `/api/v2/*` is the engine; this is a thing that drives it.
app.include_router(ai_auto_scan_router, prefix="/api")
# Market Intelligence (Phase 14) — read-only operator decision support.
# Mounted under /api/intelligence/* rather than /api/v2/* because it is a
# CLIENT of the V2 engine, not part of it: it observes and explains, and no
# endpoint on it can create a position or alter a trading decision.
app.include_router(intelligence_router, prefix="/api")
# Operations (Phase 15) — the operational layer AROUND the paper trading
# engine. Mounted under /api/operations/* for the same reason Market
# Intelligence sits outside /api/v2/*: it is a client of the engine, not part
# of it. Every endpoint observes; none can create a position, alter a trading
# decision, or repair an inconsistency it finds.
app.include_router(operations_router, prefix="/api")
# Operations Control Center — the Super Admin console's aggregation surface.
# Mounted under /api/admin/* rather than alongside the router above because
# every endpoint on it requires a Super Admin token, and /api/admin/* is
# where this application's authenticated surface lives. It is also the one
# HTTP surface permitted to see both Operations and the Validation engine, so
# it can inject the recommendation payload into the composed dashboard
# without Operations ever importing the leaf that depends on it.
app.include_router(operations_console_router, prefix="/api/admin")
# The Capture Scheduler console — the Super Admin execution interface around
# `backend/capture_scheduler/`. Its own router and its own page rather than a
# section of the Control Center above, because the two are different kinds of
# surface: that console observes the running system and every endpoint on it is
# a GET, while this one can start a capture. Mounting the one write-capable
# operations surface inside the read-only console would have made "the Control
# Center changes nothing" stop being true.
#
# What it can start is closed: `capture_scheduler.config.JOBS`, validated as an
# enum on the path. There is no endpoint here that takes a command.
app.include_router(capture_scheduler_router, prefix="/api/admin")
# The Shadow Mode Command Center — the Super Admin execution interface around
# `python -m shadow_mode`. A third kind of operations surface: the Control
# Center observes, Shadow Signals reads back recorded evidence, and this one
# runs the commands that produce it.
#
# What it can start is closed and is the CLI's own: `shadow_mode/cli.py`'s
# parser, read in a child process and re-read whenever that file changes. There
# is no endpoint here that takes a command line — a request carries a command
# name and a mapping of that command's own arguments, and every value is
# checked against the control its argument declares before an argv exists.
app.include_router(shadow_console_router, prefix="/api/admin")
# CLI Operations — the complete command catalogue, and the operations workflow
# over it. The fourth kind of operations surface, and the only one that spans
# every CLI in the checkout rather than one package.
#
# It executes only the thirty commands `cli_operations/registry.py` allows.
# Every other discovered surface is either a **deep link** to the console that
# already owns it — one command has exactly one executor — or is **denied** and
# absent from every screen: schema changes, privilege grants, ledger
# transitions, evidence deletion, CI scripts, experiment harnesses and anything
# that touches production trading state. The check happens in the service
# before an argv exists, so the page is not the boundary.
#
# Commands are discovered by AST through
# `tools/generate_developer_command_reference.py`, which CI gate 12 already
# holds equal to the tree — so this module never imports the packages it
# catalogues, and the `shadow_mode` leaf rule and the broker-SDK import
# boundary are satisfied by construction.
app.include_router(cli_operations_router, prefix="/api/admin")
app.include_router(sector_router,    prefix="/api")
app.include_router(ma_router,        prefix="/api")
app.include_router(auth_v1_router,   prefix="/api")
app.include_router(user_v1_router,   prefix="/api")
app.include_router(broker_v1_router, prefix="/api")
app.include_router(dashboard_router, prefix="/api")
app.include_router(subscription_router, prefix="/api")
app.include_router(contact_router,   prefix="/api")
app.include_router(announcement_public_router, prefix="/api")
# Super Admin — isolated router mounted under /api/admin/*
app.include_router(admin_router, prefix="/api")

# V2 Migration Stage 6 (2026-07-28): both backtest systems retired.
# `backtest/` (legacy v1 backtest) depended on analysis/scanner.py +
# strategy/*; `backtest_v2/` depended on scalper/engine.py + legacy
# strategy registry. Both are moved to backend/retired_files/. Their
# router mounts are removed. Any future backtest capability must be
# rebuilt on the V2 Signal Engine + PaperExecutor + PositionManager
# stack under a new governance-ratified specification.
#
# Phase 7.5 (2026-07-29): that rebuild is `backend/replay/`. It restores the
# Super Admin historical replay as a CLIENT of the V2 Signal Engine —
# `build_analysis_frame` + `build_signal_engine` + PaperExecutor +
# PositionManager, with stored candles substituted for the broker feed.
# Nothing from `retired_files/` is imported. Mounted under /api/admin so the
# existing admin client keeps its paths.
app.include_router(replay_router, prefix="/api/admin")

# The Research Dashboard (2026-08-08) — the read-only visualisation layer over
# the evidence the replay above has already produced.
#
# Its own router, mounted beside the Historical Replay one rather than inside
# it, because the two are different kinds of surface: `/backtest-v2/*` executes
# replays and backfills and amends the history; `/research-dashboard/*` is
# three GET endpoints that select, group and count what is already stored. It
# never executes a replay, never launches a specification, never writes and
# never installs the store — it asks `replay_run_store_present()` and reports
# the absence, which is the rule `db/schema.py` sets for an optional store.
#
# Super Admin by role, declared on the router. Same guard object as the
# Operations Control Center.
#
# It keeps the `/research-dashboard` prefix and its "research" name after the
# 2026-08-31 governance cleanup removed `backend/research/`, because the name
# describes what it reads — replay evidence — and not a package. It was always
# built outside `research/`; the two routers that did live there (the Research
# Workbench and the Market Behaviour Study's behaviour half) were removed with
# it. A rename, if the admin UI keeps a page for this, is a separate change.
app.include_router(research_dashboard_router, prefix="/api/admin")


@app.get("/health")
def health():
    return {"status": "ok", "env": settings.app_env}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
