"""
Static reference data: countries, listing venues, and the sector/industry taxonomy.

## Why this module exists

`scripts/seed_demo.py` seeded **countries only**. Everything else — exchanges, sectors, industries
— was created lazily by `get_or_create_company()` during ingestion. On a freshly migrated database
that produced:

    countries: 19   exchanges: 0   sectors: 0   industries: 0

and `seed_reference_data()` still exited 0, so a deployment could reasonably conclude the
reference layer was ready when three of its four tables were empty.

Lazy creation is not wrong in itself, but it has three real costs:

1. **Nothing can be verified before ingestion runs.** Whether the platform can resolve a venue's
   country is only discovered when an INSERT fails — which is exactly how the
   `NotNullViolation` on `exchanges.country_id` reached production.
2. **The rows it creates are poor.** The lazy path had no venue name and no timezone, so it wrote
   `name = <the MIC>` and `timezone = "UTC"` for every exchange on earth.
3. **Provider short names become duplicate venues.** FMP returns `exchangeShortName` — `"NASDAQ"`,
   not `"XNAS"`. Both were accepted as country lookups, so the same venue could be created twice
   under two identifiers, silently splitting peer groups and country rollups.

This module is the single source of truth for all three tables. `seed_reference_data()` writes it;
`app/engines/identity.py` reads it for MIC→country resolution; ingestion canonicalises provider
short names against it. One table, so the seed and the resolver cannot drift apart — the previous
arrangement let the demo universe carry `XSOF` while the resolver knew only `XBUL`, and nothing
noticed until an INSERT failed.

## What is stated here, and how confident it is

- **ISO 3166-1 alpha-2/alpha-3 codes and country names** — stated facts.
- **ISO 10383 MICs and venue names** — stated facts. Only real MICs get an `EXCHANGES` row.
  Provider short names live in `MIC_ALIASES` and resolve to the canonical MIC; they never become
  venues of their own.
- **`timezone`** — the IANA zone of the market the venue operates in. Every value is validated
  against the running `zoneinfo` database by `tests/test_seed_reference_data.py`, so a typo or an
  invented zone fails a test rather than reaching the database. It is the venue's *timezone*, and
  **not** a claim about its trading calendar or session hours: no trading-hours data is ingested
  anywhere in this repository.
- **`currency`** — the country's current official currency. Note **Bulgaria: EUR, not BGN.**
  Bulgaria adopted the euro on 1 January 2026, so the previous `BGN` entry is simply out of date.

Codes the platform cannot map to a real MIC (`OTC`, `PNK`, `AIM`) stay in `COUNTRY_BY_PROVIDER_CODE`
so ingestion can still resolve a country for them, but they are deliberately **not** seeded as
venues — asserting a MIC that ISO does not assign would be inventing an identifier.

Pure and dependency-free (stdlib only), so all of it is genuinely testable without a database.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Optional


@dataclass(frozen=True)
class CountryRef:
    iso2: str
    iso3: str
    name: str
    region: str
    currency: str


@dataclass(frozen=True)
class ExchangeRef:
    mic: str
    name: str
    country_iso2: str
    timezone: str


#: Every country any venue below sits in. `region` is a free-text grouping label used for the
#: Global/Region/Country rankings; it is not an ISO field.
COUNTRIES: tuple[CountryRef, ...] = (
    CountryRef("US", "USA", "United States", "NORTH_AMERICA", "USD"),
    CountryRef("CA", "CAN", "Canada", "NORTH_AMERICA", "CAD"),
    CountryRef("MX", "MEX", "Mexico", "LATIN_AMERICA", "MXN"),
    CountryRef("BR", "BRA", "Brazil", "LATIN_AMERICA", "BRL"),
    CountryRef("AR", "ARG", "Argentina", "LATIN_AMERICA", "ARS"),
    CountryRef("CL", "CHL", "Chile", "LATIN_AMERICA", "CLP"),
    CountryRef("GB", "GBR", "United Kingdom", "EUROPE", "GBP"),
    CountryRef("IE", "IRL", "Ireland", "EUROPE", "EUR"),
    CountryRef("DE", "DEU", "Germany", "EUROPE", "EUR"),
    CountryRef("FR", "FRA", "France", "EUROPE", "EUR"),
    CountryRef("NL", "NLD", "Netherlands", "EUROPE", "EUR"),
    CountryRef("BE", "BEL", "Belgium", "EUROPE", "EUR"),
    CountryRef("PT", "PRT", "Portugal", "EUROPE", "EUR"),
    CountryRef("ES", "ESP", "Spain", "EUROPE", "EUR"),
    CountryRef("IT", "ITA", "Italy", "EUROPE", "EUR"),
    CountryRef("CH", "CHE", "Switzerland", "EUROPE", "CHF"),
    CountryRef("SE", "SWE", "Sweden", "EUROPE", "SEK"),
    CountryRef("FI", "FIN", "Finland", "EUROPE", "EUR"),
    CountryRef("DK", "DNK", "Denmark", "EUROPE", "DKK"),
    CountryRef("NO", "NOR", "Norway", "EUROPE", "NOK"),
    CountryRef("AT", "AUT", "Austria", "EUROPE", "EUR"),
    CountryRef("PL", "POL", "Poland", "EUROPE", "PLN"),
    CountryRef("GR", "GRC", "Greece", "EUROPE", "EUR"),
    # Bulgaria adopted the euro on 1 January 2026. This entry read "BGN" until then.
    CountryRef("BG", "BGR", "Bulgaria", "EUROPE", "EUR"),
    CountryRef("JP", "JPN", "Japan", "ASIA_PACIFIC", "JPY"),
    CountryRef("HK", "HKG", "Hong Kong", "ASIA_PACIFIC", "HKD"),
    CountryRef("CN", "CHN", "China", "ASIA_PACIFIC", "CNY"),
    CountryRef("KR", "KOR", "South Korea", "ASIA_PACIFIC", "KRW"),
    CountryRef("TW", "TWN", "Taiwan", "ASIA_PACIFIC", "TWD"),
    CountryRef("SG", "SGP", "Singapore", "ASIA_PACIFIC", "SGD"),
    CountryRef("AU", "AUS", "Australia", "ASIA_PACIFIC", "AUD"),
    CountryRef("NZ", "NZL", "New Zealand", "ASIA_PACIFIC", "NZD"),
    CountryRef("IN", "IND", "India", "ASIA_PACIFIC", "INR"),
    CountryRef("ID", "IDN", "Indonesia", "ASIA_PACIFIC", "IDR"),
    CountryRef("MY", "MYS", "Malaysia", "ASIA_PACIFIC", "MYR"),
    CountryRef("TH", "THA", "Thailand", "ASIA_PACIFIC", "THB"),
    CountryRef("PH", "PHL", "Philippines", "ASIA_PACIFIC", "PHP"),
    CountryRef("IL", "ISR", "Israel", "MIDDLE_EAST", "ILS"),
    CountryRef("SA", "SAU", "Saudi Arabia", "MIDDLE_EAST", "SAR"),
    CountryRef("AE", "ARE", "United Arab Emirates", "MIDDLE_EAST", "AED"),
    CountryRef("ZA", "ZAF", "South Africa", "AFRICA", "ZAR"),
)

#: Listing venues, keyed by their real ISO 10383 MIC. Seeded up front so `exchanges.country_id`
#: is resolvable before any ingestion runs.
EXCHANGES: tuple[ExchangeRef, ...] = (
    ExchangeRef("XNYS", "New York Stock Exchange", "US", "America/New_York"),
    ExchangeRef("XNAS", "Nasdaq Stock Market", "US", "America/New_York"),
    ExchangeRef("XASE", "NYSE American", "US", "America/New_York"),
    ExchangeRef("ARCX", "NYSE Arca", "US", "America/New_York"),
    ExchangeRef("BATS", "Cboe BZX Exchange", "US", "America/New_York"),
    ExchangeRef("XTSE", "Toronto Stock Exchange", "CA", "America/Toronto"),
    ExchangeRef("XTSX", "TSX Venture Exchange", "CA", "America/Toronto"),
    ExchangeRef("NEOE", "Cboe Canada", "CA", "America/Toronto"),
    ExchangeRef("XMEX", "Bolsa Mexicana de Valores", "MX", "America/Mexico_City"),
    ExchangeRef("BVMF", "B3 - Brasil Bolsa Balcao", "BR", "America/Sao_Paulo"),
    ExchangeRef("XBSP", "B3 - Sao Paulo", "BR", "America/Sao_Paulo"),
    ExchangeRef("XBUE", "Bolsas y Mercados Argentinos", "AR", "America/Argentina/Buenos_Aires"),
    ExchangeRef("XSGO", "Santiago Stock Exchange", "CL", "America/Santiago"),
    ExchangeRef("XLON", "London Stock Exchange", "GB", "Europe/London"),
    ExchangeRef("XDUB", "Euronext Dublin", "IE", "Europe/Dublin"),
    ExchangeRef("XETR", "Xetra", "DE", "Europe/Berlin"),
    ExchangeRef("XFRA", "Frankfurt Stock Exchange", "DE", "Europe/Berlin"),
    ExchangeRef("XPAR", "Euronext Paris", "FR", "Europe/Paris"),
    ExchangeRef("XAMS", "Euronext Amsterdam", "NL", "Europe/Amsterdam"),
    ExchangeRef("XBRU", "Euronext Brussels", "BE", "Europe/Brussels"),
    ExchangeRef("XLIS", "Euronext Lisbon", "PT", "Europe/Lisbon"),
    ExchangeRef("XMAD", "Bolsa de Madrid", "ES", "Europe/Madrid"),
    ExchangeRef("XMIL", "Euronext Milan", "IT", "Europe/Rome"),
    ExchangeRef("XSWX", "SIX Swiss Exchange", "CH", "Europe/Zurich"),
    ExchangeRef("XVTX", "SIX Swiss Exchange - Blue Chip Segment", "CH", "Europe/Zurich"),
    ExchangeRef("XSTO", "Nasdaq Stockholm", "SE", "Europe/Stockholm"),
    ExchangeRef("XHEL", "Nasdaq Helsinki", "FI", "Europe/Helsinki"),
    ExchangeRef("XCSE", "Nasdaq Copenhagen", "DK", "Europe/Copenhagen"),
    ExchangeRef("XOSL", "Oslo Bors", "NO", "Europe/Oslo"),
    ExchangeRef("XWBO", "Wiener Borse", "AT", "Europe/Vienna"),
    ExchangeRef("XWAR", "Warsaw Stock Exchange", "PL", "Europe/Warsaw"),
    ExchangeRef("XATH", "Athens Stock Exchange", "GR", "Europe/Athens"),
    ExchangeRef("XBUL", "Bulgarian Stock Exchange", "BG", "Europe/Sofia"),
    ExchangeRef("XTKS", "Tokyo Stock Exchange", "JP", "Asia/Tokyo"),
    ExchangeRef("XJPX", "Japan Exchange Group", "JP", "Asia/Tokyo"),
    ExchangeRef("XHKG", "Hong Kong Stock Exchange", "HK", "Asia/Hong_Kong"),
    ExchangeRef("XSHG", "Shanghai Stock Exchange", "CN", "Asia/Shanghai"),
    ExchangeRef("XSHE", "Shenzhen Stock Exchange", "CN", "Asia/Shanghai"),
    ExchangeRef("XSSC", "Shanghai Stock Exchange - Stock Connect", "CN", "Asia/Shanghai"),
    ExchangeRef("XKRX", "Korea Exchange", "KR", "Asia/Seoul"),
    ExchangeRef("XKOS", "KOSDAQ", "KR", "Asia/Seoul"),
    ExchangeRef("XTAI", "Taiwan Stock Exchange", "TW", "Asia/Taipei"),
    ExchangeRef("XSES", "Singapore Exchange", "SG", "Asia/Singapore"),
    ExchangeRef("XASX", "Australian Securities Exchange", "AU", "Australia/Sydney"),
    ExchangeRef("XNZE", "NZX", "NZ", "Pacific/Auckland"),
    ExchangeRef("XNSE", "National Stock Exchange of India", "IN", "Asia/Kolkata"),
    ExchangeRef("XBOM", "BSE", "IN", "Asia/Kolkata"),
    ExchangeRef("XIDX", "Indonesia Stock Exchange", "ID", "Asia/Jakarta"),
    ExchangeRef("XKLS", "Bursa Malaysia", "MY", "Asia/Kuala_Lumpur"),
    ExchangeRef("XBKK", "Stock Exchange of Thailand", "TH", "Asia/Bangkok"),
    ExchangeRef("XPHS", "Philippine Stock Exchange", "PH", "Asia/Manila"),
    ExchangeRef("XTAE", "Tel Aviv Stock Exchange", "IL", "Asia/Jerusalem"),
    ExchangeRef("XSAU", "Saudi Exchange", "SA", "Asia/Riyadh"),
    ExchangeRef("XDFM", "Dubai Financial Market", "AE", "Asia/Dubai"),
    ExchangeRef("XADS", "Abu Dhabi Securities Exchange", "AE", "Asia/Dubai"),
    ExchangeRef("XJSE", "Johannesburg Stock Exchange", "ZA", "Africa/Johannesburg"),
)

#: Provider short names → the canonical MIC for the SAME venue. FMP returns `exchangeShortName`
#: ("NASDAQ") rather than a MIC ("XNAS"); without this, ingesting one company from FMP and another
#: from EODHD creates two `exchanges` rows for one venue, which quietly splits peer groups and
#: country rollups. Only mappings that are unambiguous are listed — see
#: `COUNTRY_BY_PROVIDER_CODE` for the codes that have no single canonical MIC.
MIC_ALIASES: dict[str, str] = {
    "NASDAQ": "XNAS",
    "NYSE": "XNYS",
    "AMEX": "XASE",
    "TSX": "XTSE",
    "TSXV": "XTSX",
    "LSE": "XLON",
}

#: Provider codes that identify a country but not a single ISO-assigned venue. `OTC` and `PNK` are
#: over-the-counter tiers rather than one exchange; `AIM` is a market of the London Stock Exchange
#: whose own MIC this repository has not verified. They resolve a country so ingestion can proceed,
#: and ingestion creates the venue row on demand — but they are NOT seeded as venues, because
#: asserting a MIC that ISO does not assign would be inventing an identifier.
COUNTRY_BY_PROVIDER_CODE: dict[str, str] = {
    "OTC": "US",
    "PNK": "US",
    "AIM": "GB",
}

_EXCHANGE_BY_MIC: dict[str, ExchangeRef] = {e.mic: e for e in EXCHANGES}
_COUNTRY_BY_ISO2: dict[str, CountryRef] = {c.iso2: c for c in COUNTRIES}


def canonical_mic(code: Optional[str]) -> Optional[str]:
    """Normalise a provider's exchange identifier to the canonical MIC where one is known.

    `"nasdaq"` and `"XNAS"` both become `"XNAS"`. A code with no known canonical form is returned
    upper-cased and unchanged rather than dropped — an unrecognised venue is still a venue.
    """
    if not code:
        return None
    upper = code.strip().upper()
    return MIC_ALIASES.get(upper, upper)


def exchange_for_mic(code: Optional[str]) -> Optional[ExchangeRef]:
    """The seeded venue for a MIC or provider short name, or None if it is not one we seed."""
    mic = canonical_mic(code)
    return _EXCHANGE_BY_MIC.get(mic) if mic else None


def country_for_iso2(iso2: Optional[str]) -> Optional[CountryRef]:
    return _COUNTRY_BY_ISO2.get(iso2.strip().upper()) if iso2 else None


def country_iso2_for_exchange_code(code: Optional[str]) -> Optional[str]:
    """Country of the venue a provider's exchange identifier refers to, or None.

    Checks the seeded venues first, then the provider-only codes that have no canonical MIC.
    """
    mic = canonical_mic(code)
    if not mic:
        return None
    venue = _EXCHANGE_BY_MIC.get(mic)
    if venue is not None:
        return venue.country_iso2
    return COUNTRY_BY_PROVIDER_CODE.get(mic)


def exchange_country_map() -> dict[str, str]:
    """Every exchange identifier this platform recognises → the country of its venue.

    Includes canonical MICs, their provider-short-name aliases, and the provider-only codes. This
    is what `app/engines/identity.py::EXCHANGE_COUNTRY_BY_MIC` is built from, so the resolver and
    the seed cannot disagree about a venue the way `XSOF`/`XBUL` did.
    """
    mapping = {e.mic: e.country_iso2 for e in EXCHANGES}
    for alias, mic in MIC_ALIASES.items():
        mapping[alias] = _EXCHANGE_BY_MIC[mic].country_iso2
    mapping.update(COUNTRY_BY_PROVIDER_CODE)
    return mapping


@dataclass(frozen=True)
class TaxonomyRef:
    """The sector/industry taxonomy a universe needs, with industries already attached to their
    sector so the `industries.sector_id` foreign key is resolvable at seed time."""

    sectors: tuple[tuple[str, str], ...]              # (code, display name)
    industries: tuple[tuple[str, str, str], ...]      # (code, display name, sector code)


#: The sector code ingestion falls back to when a provider profile carries no sector, and the one
#: `app/engines/industry_applicability.py` treats as "no sector-specific rules". Seeded so that
#: fallback never has to create a row mid-ingest.
DEFAULT_SECTOR_CODE = "DEFAULT"
DEFAULT_INDUSTRY_CODE = "DEFAULT"

#: Sector codes `app/engines/industry_applicability.py` has metric-applicability rules for. Seeded
#: whether or not the current universe contains one, so the taxonomy matches what the engine knows
#: how to reason about. A sector row with no companies costs nothing; a missing one means a
#: metric-applicability rule that can never match a real security.
ENGINE_KNOWN_SECTOR_CODES: tuple[str, ...] = (
    "FINANCIALS", "INSURANCE", "REAL_ESTATE", "UTILITIES", "ENERGY", "BIOTECH",
    DEFAULT_SECTOR_CODE,
)


def display_name_for_code(code: str) -> str:
    """`"CONSUMER_DISCRETIONARY"` -> `"Consumer Discretionary"`.

    A derivation from the code, not a curated label: inventing marketing names for thirteen
    industries would be stating something no source in this repository supports.
    """
    return " ".join(part.capitalize() for part in code.split("_"))


class TaxonomyConflictError(ValueError):
    """An industry code was claimed by two different sectors."""


def build_taxonomy(sector_industry_pairs) -> TaxonomyRef:
    """Derive the sector/industry taxonomy from a universe's `(sector_code, industry_code)` pairs.

    Pure: takes pairs, not profiles and not a database, so the exact rows `seed_reference_data()`
    will write can be asserted in a test without SQLAlchemy installed.

    Raises `TaxonomyConflictError` when one industry code appears under two sectors. That would
    otherwise be resolved arbitrarily by whichever company ingested first — and since peer groups
    are built by industry and then by sector, an industry attached to the wrong sector silently
    changes every percentile in it.
    """
    by_industry: dict[str, str] = {}
    sectors: set[str] = set(ENGINE_KNOWN_SECTOR_CODES)
    for sector_code, industry_code in sector_industry_pairs:
        sector = (sector_code or DEFAULT_SECTOR_CODE).upper()
        industry = (industry_code or sector_code or DEFAULT_INDUSTRY_CODE).upper()
        sectors.add(sector)
        existing = by_industry.get(industry)
        if existing is not None and existing != sector:
            raise TaxonomyConflictError(
                f"industry {industry!r} is claimed by both {existing!r} and {sector!r}; peer "
                f"groups are built by industry then sector, so one of them would be wrong"
            )
        by_industry[industry] = sector
    by_industry.setdefault(DEFAULT_INDUSTRY_CODE, DEFAULT_SECTOR_CODE)
    return TaxonomyRef(
        sectors=tuple((c, display_name_for_code(c)) for c in sorted(sectors)),
        industries=tuple(
            (code, display_name_for_code(code), by_industry[code])
            for code in sorted(by_industry)
        ),
    )


@dataclass(frozen=True)
class ReferencePlan:
    """Exactly the rows `seed_reference_data()` will insert, as plain data.

    Separated from the writing so the *content* of the seed can be asserted without a database.
    `sqlalchemy` is not installed in the development environment used for this repository (PyPI is
    blocked by egress policy), so a plan that only existed inside a DB-touching function could not
    be tested at all — and "the seed inserts the right rows" is precisely the claim that was wrong
    when three of the four reference tables came out empty.
    """

    countries: tuple[CountryRef, ...]
    exchanges: tuple[ExchangeRef, ...]
    sectors: tuple[tuple[str, str], ...]
    industries: tuple[tuple[str, str, str], ...]

    @property
    def is_empty(self) -> bool:
        return not (self.countries or self.exchanges or self.sectors or self.industries)

    def counts(self) -> dict[str, int]:
        return {
            "countries": len(self.countries),
            "exchanges": len(self.exchanges),
            "sectors": len(self.sectors),
            "industries": len(self.industries),
        }


def reference_plan(sector_industry_pairs) -> ReferencePlan:
    """The complete reference layer for a universe: countries, venues and taxonomy."""
    taxonomy = build_taxonomy(sector_industry_pairs)
    return ReferencePlan(
        countries=COUNTRIES,
        exchanges=EXCHANGES,
        sectors=taxonomy.sectors,
        industries=taxonomy.industries,
    )


def rows_to_insert(
    plan: ReferencePlan,
    existing_country_iso2: set[str],
    existing_exchange_mics: set[str],
    existing_sector_codes: set[str],
    existing_industry_codes: set[str],
) -> ReferencePlan:
    """The subset of `plan` not already present, keyed on each table's UNIQUE natural key.

    This is what makes the seed idempotent, and expressing it as a pure function is what makes
    that claim testable: call it once against empty sets and once against the full key sets, and
    the second result must be empty.
    """
    return ReferencePlan(
        countries=tuple(c for c in plan.countries if c.iso2 not in existing_country_iso2),
        exchanges=tuple(e for e in plan.exchanges if e.mic not in existing_exchange_mics),
        sectors=tuple(s for s in plan.sectors if s[0] not in existing_sector_codes),
        industries=tuple(i for i in plan.industries if i[0] not in existing_industry_codes),
    )
