"""Central configuration — env-driven, no secrets in code (pydantic-settings).

Every layer reads its config from here; nothing hardcodes a connection string or threshold.
Override via environment variables prefixed FXHNT_ (e.g. FXHNT_OPERATIONAL_DSN=postgresql+psycopg://...).
"""
from __future__ import annotations

from functools import lru_cache
from pathlib import Path

from pydantic import BaseModel, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

_DATA_DIR = Path.home() / ".fxhnt"


class GauntletSettings(BaseSettings):
    """Validation thresholds — the anti-overfitting bar. Conservative by design."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_GAUNTLET_")
    dsr_min: float = 0.95            # deflated-Sharpe floor (accounts for the full search)
    oos_min_sharpe: float = 0.0
    max_is_oos_decay: float = 0.50   # OOS Sharpe must hold ≥ (1-this) × IS
    oos_fraction: float = 0.40       # holdout fraction


class ExecutionSettings(BaseSettings):
    """Multi-strategy execution safety policy (hard-won from live paper trading)."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_EXEC_")
    hysteresis: float = 0.03       # only trade a market if target $ drifts > this × NLV (no churn)
    entry_floor: float = 0.005     # entry-from-zero floor (× NLV) — smaller than hysteresis
    data_tol: float = 0.25         # refuse to trade if broker summary vs positions disagree > this
    allow_live: bool = False       # must be explicitly true to trade a non-paper (live) account
    # Applies only when the Broker advertises `supports_fractional` (no current implementation does —
    # IBKR is whole-share-only — kept as a generic floor for any future fractional-capable broker).
    min_order_notional: float = 1.0   # a fractional delta below this $ notional is skipped as dust


class IbkrSettings(BaseSettings):
    """IB Gateway/TWS connection. Pod-to-pod must use the socat-bridged port (4004), not 4002."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_IBKR_")
    host: str = "127.0.0.1"
    port: int = 4002              # local port-forward (4002) or in-cluster socat (4004)
    client_id: int = 17
    timeout: int = 15


class PaperValidationSettings(BaseSettings):
    """Fixed PAPER-only execution envelope for the exec-validation tracks (multistrat_exec, bybit_4edge_exec).
    0.0 = OFF (exec tracks stay B2a-gated). >0 = the exec book runs continuously at this fixed paper $ size,
    bypassing B2a — but ONLY against a paper/testnet account (hard-refused on live). B2a is never read in
    paper mode."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_PAPER_VALIDATION_")
    envelope: float = 0.0


class AllocationSettings(BaseSettings):
    """Per-mode capital ceilings for the B2a allocation engine. Selected by execution.allow_live: paper by
    default (high ceiling), live is the hard $35k human gate. The RESOLVED ceiling is folded into
    allocation_policy_hash (resolve_allocation_policy), so a ceiling change stays audited + deterministic."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_ALLOCATION_")
    capital_ceiling_paper: float = 1_000_000.0
    capital_ceiling_live: float = 35_000.0
    # Which instruments' ADV/spread the equity capacity+cost model sources from: "ucits" (retail EU, DEFAULT —
    # the fund trades the less-liquid UCITS lines today) | "us" (after electing MiFID-II professional status,
    # ~€500k portfolio + trading frequency, unlocking the deep US-ETF book). Returns (book_series/target_weights)
    # always use US closes in both regimes — NEVER auto-switched on AUM; a deliberate operator action only.
    equity_execution_regime: str = "ucits"
    # Which store the UCITS-regime ADV reads from: "ibkr" = the real IBKR on-exchange volume (fetched by the
    # `ucits-volume-ibkr` CronJob), "yahoo" = the Yahoo LSE feed (default until the CronJob is running).
    ucits_volume_source: str = "yahoo"


class DatabentoSettings(BaseSettings):
    """Databento data access. The cost guard bakes in a hard-learned lesson: ALWAYS estimate cost and
    refuse any fetch above the cap before downloading (a runaway download once cost $3.5k)."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_DATABENTO_")
    max_cost_usd: float = 0.50        # refuse any single fetch estimated above this
    equity_dataset: str = "DBEQ.BASIC"
    future_dataset: str = "GLBX.MDP3"
    default_start: str = "2018-05-01"
    fetch_years: int = 2              # rolling window for the forward-tracking futures fetch: only the recent
                                      # ~fetch_years of bars (>>250d signal lookback), so we don't re-pull 16y
                                      # of all-expiry history weekly — ~90% cheaper, identical forward signals


class BybitExecutionConfig(BaseSettings):
    """Bybit testnet real-paper execution leg (design §4.5). SAFE BY DEFAULT: testnet on, live off,
    kill-switch off. Real money needs the triple-gate (testnet=false AND allow_live=true AND
    kill_switch=false) — see `can_trade_live`."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_BYBIT_EXEC_")
    api_key: str = ""                    # from env / k8s secret; never logged
    api_secret: str = ""
    testnet: bool = True                 # ccxt set_sandbox_mode (Bybit testnet)
    allow_live: bool = False             # must be explicitly true to trade real money
    kill_switch: bool = False            # halt: reconcile places zero orders
    min_order_usd: float = 10.0          # drop orders below this notional (dust floor)
    max_gross: float = 1.0               # max gross exposure as a multiple of equity (no leverage)

    @property
    def can_trade_live(self) -> bool:
        """True ONLY when all three independent gates clear: not testnet, live explicitly allowed, no kill."""
        return self.testnet is False and self.allow_live is True and self.kill_switch is False


class UcitsListing(BaseModel):
    """The resolving contract spec for one UCITS ETF: its LSE/Euronext ticker plus the ISIN/exchange/currency
    IBKR needs to resolve the USD-denominated line. The ISIN is the unambiguous key. Probed live on DU9600528
    (2026-07-15): SMART+ISIN returns 0 results for ALL these (SMART grabs the wrong US grey-market line or
    nothing) — the LSE ETF venue **LSEETF** + ISIN + currency=USD resolves each USD line cleanly, and SMART
    is in every valid-exchange set so the order still routes. `_resolve_contract` qualifies by ISIN WITHOUT a
    symbol (the map's cosmetic ticker may differ from the USD line's real symbol, e.g. IEF's USD line is CBU0,
    GLD's is IGLN), so the ISIN alone picks the right contract."""
    ticker: str                    # display/log label of the USD line (e.g. "CSPX"); resolution keys on ISIN
    isin: str                      # unambiguous security id (e.g. "IE00B5BMR087") — the real resolution key
    exchange: str = "LSEETF"       # the LSE ETF venue: LSEETF + ISIN + USD resolves the line (SMART+ISIN = 0)
    currency: str = "USD"
    # The symbol IBKR REPORTS a HOLDING of this line under (`contract.symbol` from `positions()`), which can
    # diverge from the cosmetic `ticker`. Probe-verified live 2026-07-20 (`probe-ucits-symbols`): only IEF's
    # `CBU0` diverges → IBKR reports it as `CSBGU0`; the other 5 lines report under their ticker. The cockpit's
    # NLV stray-check (`_expected_book_symbols`) keys on this, so a legitimate book position is not mislabeled
    # a stray + dropped from NLV. Defaults to `ticker` when they match (the common case).
    ibkr_symbol: str = ""
    # Yahoo LSE daily-volume ticker (ADV input for the UCITS-regime capacity/cost model). Defaults to
    # f"{ticker}.L" when left empty; set explicitly if Yahoo's symbol diverges from the display ticker.
    yahoo_ticker: str = ""
    # Per-line UCITS half-spread (bps), documented estimate — wider than the US ETF book (thinner UCITS
    # liquidity). Default 10.0 covers any line not given an explicit estimate.
    half_spread_bps: float = 10.0

    @model_validator(mode="after")
    def _default_derived_fields(self) -> UcitsListing:
        if not self.yahoo_ticker:
            self.yahoo_ticker = f"{self.ticker}.L"
        if not self.ibkr_symbol:                 # the common case: IBKR reports the holding under the ticker
            self.ibkr_symbol = self.ticker
        return self


class UcitsSettings(BaseSettings):
    """US-domiciled ticker -> UCITS equivalent (execute-only; the MODELED multistrat track stays US).
    Consumed by `route_ucits`/`apply_ucits_map` in the `execute-multistrat --venue ucits` path — never
    touches the modeled weights, only the broker order symbols + the contract used to resolve them.

    ISINs verified 2026-07 (WebSearch), all USD-denominated LSE lines except DBMF (Euronext/LSE, LU-domiciled):
      SPY  -> CSPX  IE00B5BMR087  iShares Core S&P 500 UCITS ETF (USD Acc)
      IEF  -> CBU0  IE00B3VWN518  iShares $ Treasury Bond 7-10yr UCITS ETF (USD Acc; ~$12M ADV vs the thin
              Dist IDTM line)
      GLD  -> IGLN  IE00B4ND3602  iShares Physical Gold ETC (USD; SGLN is the GBP line)
      PDBC -> ICOM  IE00BDFL4P12  iShares Diversified Commodity Swap UCITS ETF (USD)
      DBMF -> DBMF  LU2951555585  iMGP DBi Managed Futures Fund R USD UCITS ETF (LSE ticker DBMF, USD line;
              GBX line is DBMG). This IS a clean managed-futures UCITS equivalent (launched 2025-03), but it
              is NEW and SMALL (~EUR 100m AUM, TER 0.75%) so its spread/liquidity is thin — the runtime route
              auto-skips+logs it if the contract fails to resolve or has no price, so a thin book can't wedge
              the rebalance. Operator: watch this sleeve's fills.
    """
    model_config = SettingsConfigDict(env_prefix="FXHNT_UCITS_")
    map: dict[str, UcitsListing] = Field(default_factory=lambda: {
        "SPY": UcitsListing(ticker="CSPX", isin="IE00B5BMR087", half_spread_bps=1.5),
        "IEF": UcitsListing(ticker="CBU0", isin="IE00B3VWN518", yahoo_ticker="CBU0.L",  # Acc line, ~$12M ADV
                            ibkr_symbol="CSBGU0",  # IBKR reports the HOLDING under CSBGU0 (probe 2026-07-20)
                            half_spread_bps=2.0),
        "GLD": UcitsListing(ticker="IGLN", isin="IE00B4ND3602",   # USD line = IGLN (SGLN is the GBP line)
                            half_spread_bps=2.0),
        "PDBC": UcitsListing(ticker="ICOM", isin="IE00BDFL4P12", half_spread_bps=8.0),
        "DBMF": UcitsListing(ticker="DBMF", isin="LU2951555585", yahoo_ticker="DBMF.L", half_spread_bps=30.0),
    })


class AgentSettings(BaseSettings):
    """The research agent's LLM. Provider/model are swappable (local Ollama now; cluster/API later)."""
    model_config = SettingsConfigDict(env_prefix="FXHNT_AGENT_")
    provider: str = "ollama"                       # ollama | openai
    model: str = "qwen2.5:3b"
    base_url: str = "http://localhost:11434"
    temperature: float = 0.2
    recursion_limit: int = 40                      # max agent reasoning/tool steps


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="FXHNT_", env_file=".env", extra="ignore")

    # Operational store (relational: strategies, runs, verdicts, positions, trades).
    # SQLite for dev/test; set to a postgresql+psycopg DSN in production.
    operational_dsn: str = Field(default=f"sqlite:///{_DATA_DIR / 'operational.db'}")
    # Analytical store (columnar: market data, backtest timeseries) — DuckDB embedded file.
    analytical_path: str = Field(default=str(_DATA_DIR / "analytical.duckdb"))
    # Warehouse (point-in-time SSOT: bars, funding, microstructure features) — DuckDB embedded file.
    warehouse_path: str = Field(default=str(_DATA_DIR / "warehouse.duckdb"))
    # Feature-store backend: "postgres" (DEFAULT — the networked TimescaleFeatureStore over operational_dsn,
    # which the live stack runs on) or "duckdb" (the embedded warehouse file). Selected via get_feature_store().
    # SAFETY: postgres is the default so a Job that forgets FXHNT_FEATURE_STORE can't silently write to a
    # throwaway DuckDB file instead of the live `features` table; "duckdb" is the explicit opt-in for
    # embedded/local use and the B3 backtest.
    feature_store: str = Field(default="postgres")
    # Warehouse table the (Timescale) feature store reads/writes — the single knob that selects WHICH
    # exchange's data ALL edges run on: "features" (default — the legacy combined-book venue's data) vs
    # "bybit_features" (Bybit funding + klines). Set FXHNT_WAREHOUSE_TABLE=bybit_features to route every
    # get_feature_store consumer (book, sim, backfill, snapshot, ingest reads) at the Bybit warehouse with NO
    # per-sleeve change. DuckDb path ignores this (single warehouse file). Bybit INGEST jobs target
    # bybit_features explicitly, unaffected.
    warehouse_table: str = Field(default="features")
    # Backtest warehouse: a DEDICATED, isolated warehouse for the survivorship-free historical-EOD
    # ingest (B3a). NEVER the live cockpit warehouse — the historical pull is large + long-running, so
    # it gets its own DuckDB file on its own PVC. The cluster Job sets this to /backtest-data/warehouse.duckdb.
    backtest_warehouse_path: str = Field(default="/backtest-data/warehouse.duckdb")
    # crypto_pit_dir (the survivorship-free npz panel fed by the retired legacy-venue klines fetch) RETIRED
    # (Phase 0b Task 7e) along with its fetch/ingest/research surface — Bybit is fxhnt's sole crypto venue.
    # worst_basis_path (the legacy-venue-fed Parquet side-store) RETIRED (Phase 0b Task 7c) — the paper-book
    # carry sleeve now reads intraday `worst_basis` from the `bybit_features` warehouse table (populated by
    # `bybit-ingest-worst-basis`), same as bybit_carry_revalidate.
    paper_capital: float = 100_000.0   # starting paper book capital for the cockpit paper view
    paper_enabled: bool = True          # toggle the daily-rebalance paper-book persistence hook (T7)
    # Staleness/health axis: a recompute track whose source data (as_of) OR freeze table hasn't advanced in
    # more than this many BUSINESS days reads as STALE (a red cockpit badge + ERROR log). Default 3 allows
    # the normal ~1-business-day ingest lag; the anti-silent-rot threshold that would have caught a
    # strategy silently rotting for weeks.
    stale_after_business_days: int = 3

    cost_bps_per_turnover: float = 10.0   # round-trip cost model (bps of traded notional)
    # SINGLE SOURCE for the Bybit-perp per-turnover reconciliation cost — the MEASURED liquid-book cost (the
    # "mirage" finding: Sharpe 2.11 at 5.5bp drag, see project_fxhnt_measured_cost_mirage_and_postgres). Read
    # by every Bybit forward track's nav asset (positioning/xsfunding/unlock/bybit_4edge/bybit_4edge_levered)
    # AND their `_persist_bybit_book` reconciliation ref, so the forward-charged cost and the ref's backtest
    # cost can never diverge. Distinct from `cost_bps_per_turnover` (equity/tradfi venue) — do NOT conflate.
    bybit_cost_bps: float = 5.5
    gauntlet: GauntletSettings = Field(default_factory=GauntletSettings)
    execution: ExecutionSettings = Field(default_factory=ExecutionSettings)
    ibkr: IbkrSettings = Field(default_factory=IbkrSettings)
    databento: DatabentoSettings = Field(default_factory=DatabentoSettings)
    agent: AgentSettings = Field(default_factory=AgentSettings)
    bybit_exec: BybitExecutionConfig = Field(default_factory=BybitExecutionConfig)
    paper_validation: PaperValidationSettings = Field(default_factory=PaperValidationSettings)
    allocation: AllocationSettings = Field(default_factory=AllocationSettings)
    ucits: UcitsSettings = Field(default_factory=UcitsSettings)

    def ensure_dirs(self) -> None:
        _DATA_DIR.mkdir(parents=True, exist_ok=True)
        Path(self.analytical_path).parent.mkdir(parents=True, exist_ok=True)
        Path(self.warehouse_path).parent.mkdir(parents=True, exist_ok=True)


@lru_cache
def get_settings() -> Settings:
    s = Settings()
    s.ensure_dirs()
    return s
