#!/usr/bin/env python3
from __future__ import annotations

import csv
import json
import math
from dataclasses import dataclass
from pathlib import Path

import matplotlib
import numpy as np

matplotlib.use("Agg")
import matplotlib.pyplot as plt

REPO_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = REPO_ROOT / "data" / "sk-hynix-revenue"
SCRIPT_PATH = Path(__file__).resolve()
CSV_PATH = DATA_DIR / "sk_hynix_revenue_market.csv"
SUMMARY_PATH = DATA_DIR / "model_summary.json"
BACKGROUND_PATH = DATA_DIR / "background_info.html"
FORECAST_PLOT_PATH = DATA_DIR / "forecast_path.png"
REFERENCE_PLOT_PATH = DATA_DIR / "reference_classes.png"
LIVE_THRESHOLDS_PATH = DATA_DIR / "live_thresholds.json"

MARKET_CODE = "sk-hynix-rev"
MARKET_TITLE = "SK hynix annual revenue"
MARKET_BUDGET = 300
MARKET_DECAY_RATE = 0.005
PROBABILITY_DECIMALS = 3
N_THRESHOLDS = 50
SIM_DRAWS = 90000
RNG_SEED = 20260910

YEARS = list(range(2026, 2030))
FIRST_YEAR = YEARS[0]
LAST_YEAR = YEARS[-1]
YEAR_ENDS = {year: f"{year}-12-31T23:59:59Z" for year in YEARS}

# Units: KRW trillions. Latest SK hynix releases present K-IFRS revenue in KRW.
SK_HYNIX_REVENUE_TN = {
    2015: 18.798,
    2016: 17.198,
    2017: 30.109,
    2018: 40.445,
    2019: 26.991,
    2020: 31.900,
    2021: 42.998,
    2022: 44.622,
    2023: 32.7657,
    2024: 66.1930,
    2025: 97.1467,
}

MICRON_REVENUE_USD_BN = {
    2015: 16.192,
    2016: 12.399,
    2017: 20.322,
    2018: 30.391,
    2019: 23.406,
    2020: 21.435,
    2021: 27.705,
    2022: 30.758,
    2023: 15.540,
    2024: 25.111,
    2025: 37.378,
}

# Analyst expectations are incorporated through a partial-pooling layer.
# Each yearly latent analyst target is inferred from current published
# expectations and then pooled with the structural memory-cycle path using
# inverse-variance weights rather than treated as a fixed override.
ANALYST_EXPECTATIONS_TN = {
    2026: {
        "mirae_research_2026_05_04": 355.2,
    },
    2027: {
        "mirae_research_2026_05_04": 507.5,
    },
}
ANALYST_ERROR_PRIOR_LOG = {
    2026: 0.300,
    2027: 0.500,
}

LAST_FY_GROWTH_PCT = (SK_HYNIX_REVENUE_TN[2025] / SK_HYNIX_REVENUE_TN[2024] - 1.0) * 100.0
FOURTH_QUARTER_2025_TN = 32.8267
Q4_2025_ANNUALIZED_TN = FOURTH_QUARTER_2025_TN * 4.0
FIRST_QUARTER_2026_TN = 52.5763
Q1_2026_ANNUALIZED_TN = FIRST_QUARTER_2026_TN * 4.0
SECOND_QUARTER_2026_TN = 79.318746
FIRST_HALF_2026_TN = 131.895033
# May broker forecast was 82.3tn for Q2; scale its remaining quarters by
# actual / forecast. This is our mechanical adjustment, not a new broker call.
Q2_SURPRISE_RATIO = SECOND_QUARTER_2026_TN / 82.3
BROKER_H2_2026_TN = (101.9 + 118.3) * Q2_SURPRISE_RATIO
AS_OF_DATE = "2026-09-10"
MARKET_END_DATE = f"{LAST_YEAR}-12-31T23:59:59Z"
MARKET_RESOLUTION_DATE = f"{LAST_YEAR + 1}-04-30T23:59:59Z"

# AI 2027 supplies physical production growth, not company revenue growth.
# Exposure, price, share and uncertainty are our explicit translation priors.
AI2027_ASSUMPTIONS = {
    "source": "https://ai-2027.com/research/compute-forecast",
    "source_date": "2025-04",
    "physical_growth_2027": 1.65,
    "physical_growth_after_2027": 1.25,
    "post_2027_extension": "Apply the source's post-2027 chip-production slowdown to AI memory demand; this is a translation assumption.",
    "ai_linked_revenue_fraction_beta": [6, 2],
    "ai_linked_revenue_fraction_mean": 0.75,
    "annual_price_mix_multiplier": 1.03,
    "annual_market_share_multiplier": 0.99,
    "persistent_physical_growth_log_sd": 0.12,
    "shared_memory_cycle_log_sd": 0.20,
    "other_memory_growth_prior_mean": 0.08,
    "other_memory_growth_prior_sd_log": 0.10,
    "overlap_adjusted_effective_history_n": 10,
    "status": "Conditional AI 2027 worldview; translation parameters are judgemental, not an empirically calibrated company forecast.",
}
OFFICIAL_SOURCE_LINKS = [
    ("AI 2027 compute production forecast (April 2025)", AI2027_ASSUMPTIONS["source"]),
    ("SK hynix Q2 2026 results", "https://news.skhynix.com/en/q2-2026-business-results/"),
    ("SK hynix July 2026 Form 6-K", "https://www.sec.gov/Archives/edgar/data/2120882/000119312526321989/d115239d6k.htm"),
    (
        "SK hynix FY2025 results",
        "https://news.skhynix.com/sk-hynix-announces-fy25-financial-results/",
    ),
    (
        "SK hynix 1Q26 results",
        "https://news.skhynix.com/q1-2026-business-results/",
    ),
    (
        "SK hynix FY2024 results",
        "https://news.skhynix.com/sk-hynix-announces-4q24-financial-results/",
    ),
    (
        "SK hynix FY2022 results",
        "https://news.skhynix.com/sk-hynix-reports-2022-and-fourth-quarter-financial-results/",
    ),
    (
        "Micron FY2025 results",
        "https://investors.micron.com/news-releases/news-release-details/micron-technology-inc-reports-results-fourth-quarter-and-full-8",
    ),
    (
        "Mirae Asset Securities SK hynix May 2026 estimates",
        "https://img.securities.miraeasset.com/ezrlink/report/183/20260504134519223_en.pdf",
    ),
    (
        "Newsquawk on 1Q26 result and 2Q26 shipment outlook",
        "https://www.newsquawk.com/headlines/sk-hynix-000660-ks-q1-2026-krw-net-profit-403tln-exp-304tln-revenue-526tln-exp-536tln-operating-profit-376tln-exp-379tln-sees-q2-dram-shipments-to-rise-high-single-digits-and-nand-shipments-to-rise-by-mid-teens-qq",
    ),
    (
        "Nature (1996): scaling behaviour in the growth of companies",
        "https://www.nature.com/articles/379804a0",
    ),
    (
        "Bottazzi & Secchi (2006): explaining the distribution of firm growth rates",
        "https://ideas.repec.org/a/taf/teurst/v40y2006i2p235-256.html",
    ),
]


@dataclass(frozen=True)
class GrowthObservation:
    company: str
    start_year: int
    end_year: int
    start_revenue: float
    end_revenue: float
    growth_log: float
    growth_pct: float


def clamp(value: float, low: float, high: float) -> float:
    return min(high, max(low, value))


def standardized_student_t(rng: np.random.Generator, df: int) -> float:
    scale = math.sqrt(df / (df - 2))
    return float(rng.standard_t(df) / scale)


def percentile(values: np.ndarray, q: float) -> float:
    return float(np.percentile(values, q))


def tn_label(value_tn: float) -> str:
    if value_tn >= 100.0:
        return f"₩{value_tn:,.0f}tn"
    if value_tn >= 10.0:
        return f"₩{value_tn:,.1f}tn".replace(".0tn", "tn")
    return f"₩{value_tn:,.2f}tn".replace(".00tn", "tn")


def axis_label(value_tn: float) -> str:
    if value_tn >= 100.0:
        return f"{value_tn:,.0f}"
    if value_tn >= 10.0:
        return f"{value_tn:,.1f}".replace(".0", "")
    return f"{value_tn:,.2f}".replace(".00", "")


def nice_round(value: float) -> float:
    if value < 20:
        step = 1
    elif value < 50:
        step = 2
    elif value < 100:
        step = 5
    elif value < 250:
        step = 5
    elif value < 500:
        step = 10
    elif value < 1000:
        step = 20
    else:
        step = 50
    rounded = round(value / step) * step
    return float(max(step, rounded))


def build_thresholds(samples: np.ndarray, n_thresholds: int) -> list[float]:
    low = max(20.0, percentile(samples, 0.4) * 0.82)
    high = percentile(samples, 99.6) * 1.08
    raw = np.geomspace(low, high, 220)
    unique_sorted = sorted({nice_round(float(x)) for x in raw})

    while len(unique_sorted) < n_thresholds:
        high *= 1.10
        raw = np.geomspace(low, high, 320)
        unique_sorted = sorted({nice_round(float(x)) for x in raw})

    picks = np.linspace(0, len(unique_sorted) - 1, n_thresholds)
    thresholds = [unique_sorted[int(round(idx))] for idx in picks]
    deduped: list[float] = []
    for threshold in thresholds:
        if deduped and threshold <= deduped[-1]:
            threshold = nice_round(deduped[-1] * 1.035)
            if threshold <= deduped[-1]:
                threshold = deduped[-1] + 1.0
        deduped.append(float(threshold))
    return deduped[:n_thresholds]


def extend_thresholds_for_tail(samples: np.ndarray, thresholds: list[float]) -> list[float]:
    if not thresholds:
        return thresholds

    max_existing = max(thresholds)
    target_high = max(percentile(samples, 99.4), percentile(samples, 95) * 1.18)
    if target_high <= max_existing * 1.05:
        return thresholds

    additions: list[float] = []
    candidate = nice_round(max_existing * 1.08)
    if candidate <= max_existing:
        candidate = max_existing + 10.0

    while candidate <= target_high * 1.03 and len(additions) < 16:
        if candidate > max_existing and candidate not in thresholds and candidate not in additions:
            additions.append(candidate)
        candidate = nice_round(candidate * 1.10)
        if additions and candidate <= additions[-1]:
            candidate = additions[-1] + max(10.0, nice_round(additions[-1] * 0.05))

    return sorted(thresholds + additions)


def load_live_thresholds() -> dict[int, list[float]]:
    if not LIVE_THRESHOLDS_PATH.exists():
        return {}
    payload = json.loads(LIVE_THRESHOLDS_PATH.read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        return {}

    ladders: dict[int, list[float]] = {}
    for year_text, values in payload.items():
        try:
            year = int(year_text)
        except (TypeError, ValueError):
            continue
        if not isinstance(values, list):
            continue
        cleaned = sorted({float(v) for v in values if float(v) > 0})
        if cleaned:
            ladders[year] = cleaned
    return ladders


def compute_growth_observations() -> dict[str, list[GrowthObservation]]:
    rows: dict[str, list[GrowthObservation]] = {"sk_hynix": [], "micron": []}
    for company, series, bucket in [
        ("SK hynix", SK_HYNIX_REVENUE_TN, "sk_hynix"),
        ("Micron", MICRON_REVENUE_USD_BN, "micron"),
    ]:
        years = sorted(series)
        for start_year, end_year in zip(years, years[1:]):
            start_revenue = series[start_year]
            end_revenue = series[end_year]
            rows[bucket].append(
                GrowthObservation(
                    company=company,
                    start_year=start_year,
                    end_year=end_year,
                    start_revenue=start_revenue,
                    end_revenue=end_revenue,
                    growth_log=math.log(end_revenue / start_revenue),
                    growth_pct=(end_revenue / start_revenue - 1.0) * 100.0,
                )
            )
    return rows


def analyst_partial_pool(
    year: int,
    horizon: int,
    structural_revenue: float,
    rng: np.random.Generator,
) -> tuple[float, dict[str, float]] | None:
    estimates = ANALYST_EXPECTATIONS_TN.get(year)
    if not estimates:
        return None

    estimate_values = np.array(list(estimates.values()), dtype=float)
    if year == 2026:
        estimate_values = np.array([BROKER_H2_2026_TN])
    else:
        estimate_values *= Q2_SURPRISE_RATIO
    estimate_logs = np.log(estimate_values)
    analyst_center_log = float(np.mean(estimate_logs))
    analyst_dispersion_log = float(np.std(estimate_logs, ddof=1)) if len(estimate_logs) > 1 else 0.0

    # Guidance/consensus should dominate near-term when it has stronger
    # predictive power than the structural cycle simulation.
    base_analyst_error_log = ANALYST_ERROR_PRIOR_LOG.get(year, 0.075 + 0.030 * horizon)
    analyst_signal_sd = math.sqrt(base_analyst_error_log**2 + analyst_dispersion_log**2)
    structural_signal_sd = 0.25

    analyst_draw_log = analyst_center_log
    structural_log = math.log(structural_revenue)

    analyst_precision = 1.0 / (analyst_signal_sd**2)
    structural_precision = 1.0 / (structural_signal_sd**2)
    pooled_precision = analyst_precision + structural_precision
    pooled_mean_log = (
        analyst_precision * analyst_draw_log + structural_precision * structural_log
    ) / pooled_precision
    pooled_sd_log = math.sqrt(1.0 / pooled_precision)

    # Shared uncertainty is not diversified away: add a common 0.15 log-sd
    # model-discrepancy term and use Student-t tails for cycle surprises.
    predictive_sd = math.sqrt(pooled_sd_log**2 + 0.15**2)
    shock = clamp(standardized_student_t(rng, 5) * predictive_sd, -2.5, 2.5)
    pooled_revenue = float(math.exp(pooled_mean_log + shock))
    diagnostics = {
        "analyst_center_tn": float(math.exp(analyst_center_log)),
        "analyst_dispersion_log": analyst_dispersion_log,
        "base_analyst_error_log": base_analyst_error_log,
        "analyst_signal_sd_log": analyst_signal_sd,
        "structural_signal_sd_log": structural_signal_sd,
        "pooled_weight_analyst": analyst_precision / pooled_precision,
        "pooled_weight_structural": structural_precision / pooled_precision,
    }
    return pooled_revenue, diagnostics


def simulate_paths() -> tuple[np.ndarray, dict[str, float]]:
    rng = np.random.default_rng(RNG_SEED)
    samples = np.zeros((SIM_DRAWS, len(YEARS)))
    growths = compute_growth_observations()
    sk_logs = np.array([row.growth_log for row in growths["sk_hynix"]], dtype=float)
    micron_logs = np.array([row.growth_log for row in growths["micron"]], dtype=float)
    all_memory_logs = np.concatenate([sk_logs, micron_logs])

    memory_mu = float(np.mean(all_memory_logs))
    memory_sd = float(np.std(all_memory_logs, ddof=1))
    sk_cycle_sd = float(np.std(sk_logs, ddof=1))
    boom_mean = float(np.mean([x for x in all_memory_logs if x > 0.20]))
    bust_mean = float(np.mean([x for x in all_memory_logs if x < -0.20]))
    pooled_weight_records: dict[int, list[float]] = {year: [] for year in ANALYST_EXPECTATIONS_TN}
    analyst_center_records: dict[int, list[float]] = {year: [] for year in ANALYST_EXPECTATIONS_TN}
    analyst_signal_sd_records: dict[int, list[float]] = {year: [] for year in ANALYST_EXPECTATIONS_TN}
    structural_signal_sd_records: dict[int, list[float]] = {year: [] for year in ANALYST_EXPECTATIONS_TN}

    # Normal-normal partial pooling for non-AI memory growth. Paired
    # company histories get 10 effective observations, not 20 independent ones.
    history_var = memory_sd**2 / 10
    prior_var = 0.10**2
    history_weight = prior_var / (prior_var + history_var)
    other_mu = history_weight * memory_mu + (1-history_weight) * math.log1p(0.08)
    other_mu_sd = math.sqrt(1 / (1/prior_var + 1/history_var))
    for draw_idx in range(SIM_DRAWS):
        ai_fraction = rng.beta(6, 2)
        production_bias = rng.normal(0, 0.12)
        other_growth = rng.normal(other_mu, other_mu_sd)
        cycle = 0.0
        revenue_prev = SK_HYNIX_REVENUE_TN[2025]
        for year_idx, year in enumerate(YEARS):
            if year == 2026:
                # Translate 1.65x/year physical growth into two remaining
                # quarters, weighted by uncertain AI revenue exposure.
                annual_factor = ai_fraction * 1.65 * 1.03 * 0.99 + (1-ai_fraction)*math.exp(other_growth)
                quarter_factor = annual_factor**0.25
                raw_revenue = SECOND_QUARTER_2026_TN * (quarter_factor + quarter_factor**2)
            else:
                physical = 1.65 if year == 2027 else 1.25
                cycle = 0.5 * cycle + standardized_student_t(rng, 5) * 0.20
                # Shared pricing/cycle shock affects both revenue streams.
                # No additional memory-per-chip multiplier: avoid counting
                # the source's richer stacks twice.
                ai_growth = math.log(physical) + production_bias + math.log(1.03*0.99) + cycle
                other_step = other_growth + cycle
                ai_revenue = revenue_prev * ai_fraction * math.exp(clamp(ai_growth, -2, 2))
                other_revenue = revenue_prev * (1-ai_fraction) * math.exp(clamp(other_step, -2, 2))
                raw_revenue = ai_revenue + other_revenue
                ai_fraction = ai_revenue / raw_revenue
            pooled = analyst_partial_pool(year, year_idx, raw_revenue, rng)
            if pooled is not None:
                revenue_next, pooled_diag = pooled
                pooled_weight_records[year].append(pooled_diag["pooled_weight_analyst"])
                analyst_center_records[year].append(pooled_diag["analyst_center_tn"])
                analyst_signal_sd_records[year].append(pooled_diag["analyst_signal_sd_log"])
                structural_signal_sd_records[year].append(pooled_diag["structural_signal_sd_log"])
            else:
                revenue_next = raw_revenue
            if year == 2026:
                revenue_next += FIRST_HALF_2026_TN
            samples[draw_idx, year_idx] = revenue_next
            revenue_prev = revenue_next

    diagnostics = {
        "other_memory_history_weight": history_weight,
        "other_memory_pooled_growth_pct": math.expm1(other_mu)*100,
        "sk_hynix_mean_growth_pct": (math.exp(float(np.mean(sk_logs))) - 1.0) * 100.0,
        "sk_hynix_sd_log_growth": sk_cycle_sd,
        "micron_mean_growth_pct": (math.exp(float(np.mean(micron_logs))) - 1.0) * 100.0,
        "memory_mean_growth_pct": (math.exp(memory_mu) - 1.0) * 100.0,
        "memory_sd_log_growth": memory_sd,
        "memory_boom_mean_growth_pct": (math.exp(boom_mean) - 1.0) * 100.0,
        "memory_bust_mean_growth_pct": (math.exp(bust_mean) - 1.0) * 100.0,
        "q4_2025_annualized_tn": Q4_2025_ANNUALIZED_TN,
        "q1_2026_annualized_tn": Q1_2026_ANNUALIZED_TN,
    }
    for year in sorted(ANALYST_EXPECTATIONS_TN):
        diagnostics[f"analyst_center_{year}_tn"] = float(np.mean(analyst_center_records[year]))
        diagnostics[f"pooled_weight_analyst_{year}"] = float(np.mean(pooled_weight_records[year]))
        diagnostics[f"analyst_signal_sd_log_{year}"] = float(np.mean(analyst_signal_sd_records[year]))
        diagnostics[f"structural_signal_sd_log_{year}"] = float(np.mean(structural_signal_sd_records[year]))
        diagnostics[f"analyst_error_prior_log_{year}"] = ANALYST_ERROR_PRIOR_LOG[year]
    return samples, diagnostics


def summarize_samples(samples: np.ndarray) -> dict[int, dict[str, float | list[float]]]:
    live_thresholds = load_live_thresholds()
    summary: dict[int, dict[str, float | list[float]]] = {}
    for idx, year in enumerate(YEARS):
        year_samples = samples[:, idx]
        thresholds = live_thresholds.get(year) or build_thresholds(year_samples, N_THRESHOLDS)
        # Preserve the live ladder for this probability-only refresh.
        if year not in live_thresholds:
            thresholds = extend_thresholds_for_tail(year_samples, thresholds)
        summary[year] = {
            "mean_tn": float(np.mean(year_samples)),
            "median_tn": float(np.median(year_samples)),
            "p05_tn": percentile(year_samples, 5),
            "p10_tn": percentile(year_samples, 10),
            "p25_tn": percentile(year_samples, 25),
            "p75_tn": percentile(year_samples, 75),
            "p90_tn": percentile(year_samples, 90),
            "p95_tn": percentile(year_samples, 95),
            "thresholds_tn": thresholds,
            "growth_p10_pct": percentile((year_samples / (SK_HYNIX_REVENUE_TN[2025] if idx == 0 else samples[:, idx-1]) - 1) * 100, 10),
            "growth_p50_pct": percentile((year_samples / (SK_HYNIX_REVENUE_TN[2025] if idx == 0 else samples[:, idx-1]) - 1) * 100, 50),
            "growth_p90_pct": percentile((year_samples / (SK_HYNIX_REVENUE_TN[2025] if idx == 0 else samples[:, idx-1]) - 1) * 100, 90),
        }
    return summary


def survival_probabilities(samples: np.ndarray, thresholds: list[float]) -> list[float]:
    n = len(samples)
    probabilities: list[float] = []
    prev = 0.999
    for threshold in thresholds:
        probability = sum(1 for value in samples if value >= threshold) / n
        probability = clamp(round(probability, PROBABILITY_DECIMALS), 0.005, 0.995)
        probability = min(prev, probability)
        probabilities.append(probability)
        prev = probability
    return probabilities


def write_market_csv(samples: np.ndarray, summary: dict[int, dict[str, float | list[float]]], diagnostics: dict[str, float]) -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    with CSV_PATH.open("w", newline="", encoding="utf-8") as handle:
        handle.write(f"# market_code: {MARKET_CODE}\n")
        handle.write(f"# market_title: {MARKET_TITLE}\n")
        handle.write("# market_type: count\n")
        handle.write("# market_visibility: public\n")
        handle.write("# market_status: draft\n")
        handle.write(f"# market_budget: {MARKET_BUDGET}\n")
        handle.write(f"# market_decay_rate: {MARKET_DECAY_RATE:.3f}\n")
        handle.write(
            "# market_resolution_criteria: Each yearly projection group resolves to SK hynix Inc.'s consolidated annual revenue "
            f"for that fiscal year (FY{FIRST_YEAR} through FY{LAST_YEAR}), on a non-cumulative basis, in KRW trillions, from the company's "
            "audited annual report, official full-year financial results release, or K-IFRS financial statements. If SK hynix "
            "materially changes reporting structure, use the most directly comparable consolidated revenue figure explicitly "
            "disclosed for that fiscal year. Do not convert from KRW into another currency for resolution.\n"
        )
        handle.write("# market_x_unit: ₩Xtn\n")
        handle.write("# market_number_format: decimal\n")
        handle.write(f"# market_end_date: {MARKET_END_DATE}\n")
        handle.write(f"# market_resolution_date: {MARKET_RESOLUTION_DATE}\n")
        handle.write("# market_cumulative: false\n")
        handle.write("# market_background_info_path: background_info.html\n")
        handle.write(
            '# market_svelte_params: {"scaleType":"log","scaleBase":1.10,"timeCadence":"yearly","countUnitDisplay":"inline","countAxisLabel":"Annual revenue (₩tn)","projectionEndLabelPrefix":"FY ends"}\n'
        )
        handle.write(
            "# generated_note: memory_revenue_model, draws="
            f"{SIM_DRAWS}, seed={RNG_SEED}, base_thresholds_per_year={N_THRESHOLDS}, "
            f"last_fy_growth_pct={LAST_FY_GROWTH_PCT:.1f}, memory_mean_growth_pct={diagnostics['memory_mean_growth_pct']:.1f}, "
            f"memory_sd_log_growth={diagnostics['memory_sd_log_growth']:.3f}, analyst_center_2026_tn={diagnostics['analyst_center_2026_tn']:.1f}, "
            f"pooled_weight_analyst_2026={diagnostics['pooled_weight_analyst_2026']:.2f}, "
            f"pooled_weight_analyst_2027={diagnostics['pooled_weight_analyst_2027']:.2f}\n"
        )
        for year in YEARS:
            item = summary[year]
            handle.write(f"# generated_median_{year}: {item['median_tn']:.1f}\n")
            handle.write(f"# generated_interval80_{year}: [{item['p10_tn']:.1f},{item['p90_tn']:.1f}]\n")
        handle.write("\n")

        writer = csv.writer(handle, lineterminator="\n")
        writer.writerow(
            [
                "projection_group",
                "threshold_decimal",
                "threshold_date",
                "initial_probability",
                "label",
                "end_date",
                "decay_rate",
                "budget_allocation",
                "status",
            ]
        )

        for idx, year in enumerate(YEARS):
            year_end = YEAR_ENDS[year]
            thresholds_tn = summary[year]["thresholds_tn"]
            probabilities = survival_probabilities(samples[:, idx], thresholds_tn)
            for threshold_tn, probability in zip(thresholds_tn, probabilities):
                writer.writerow(
                    [
                        year_end,
                        f"{threshold_tn:.3f}".rstrip("0").rstrip("."),
                        "",
                        f"{probability:.3f}",
                        f"FY{year} revenue",
                        year_end,
                        f"{MARKET_DECAY_RATE:.3f}",
                        "",
                        "open",
                    ]
                )


def write_summary_json(summary: dict[int, dict[str, float | list[float]]], diagnostics: dict[str, float]) -> None:
    growths = compute_growth_observations()
    payload = {
        "as_of_date": AS_OF_DATE,
        "ai2027_worldview": AI2027_ASSUMPTIONS,
        "q2_2026_revenue_tn": SECOND_QUARTER_2026_TN,
        "h1_2026_revenue_tn": FIRST_HALF_2026_TN,
        "q2_actual_to_may_forecast_ratio": Q2_SURPRISE_RATIO,
        "adjusted_broker_h2_2026_tn": BROKER_H2_2026_TN,
        "resolution_check": "No eligible annual resolutions: FY2026-FY2029 remain incomplete.",
        "model_assumptions": "AI 2027 physical-production worldview mapped through uncertain AI exposure, prices and market share. Known H1 fixed; H2 and FY2027 partially pooled with adjusted May broker forecast. Other memory growth partially pooled with overlap-adjusted historical observations. Shared Student-t cycle shocks; no revenue-size penalty.",
        "seed": RNG_SEED,
        "draws": SIM_DRAWS,
        "units": "KRW trillions for SK hynix revenue thresholds",
        "historical_revenue_tn": SK_HYNIX_REVENUE_TN,
        "last_financial_year_growth_pct": round(LAST_FY_GROWTH_PCT, 2),
        "q4_2025_revenue_tn": FOURTH_QUARTER_2025_TN,
        "q4_2025_annualized_tn": round(Q4_2025_ANNUALIZED_TN, 2),
        "q1_2026_revenue_tn": FIRST_QUARTER_2026_TN,
        "q1_2026_annualized_tn": round(Q1_2026_ANNUALIZED_TN, 2),
        "analyst_expectations_tn": ANALYST_EXPECTATIONS_TN,
        "analyst_error_prior_log": ANALYST_ERROR_PRIOR_LOG,
        "reference_classes": {
            "sk_hynix_cycle_observations": [row.__dict__ for row in growths["sk_hynix"]],
            "micron_cycle_observations": [row.__dict__ for row in growths["micron"]],
            "diagnostics": diagnostics,
        },
        "yearly_forecast_tn": {
            str(year): {
                "mean_tn": round(float(item["mean_tn"]), 1),
                "median_tn": round(float(item["median_tn"]), 1),
                "p05_tn": round(float(item["p05_tn"]), 1),
                "p10_tn": round(float(item["p10_tn"]), 1),
                "p25_tn": round(float(item["p25_tn"]), 1),
                "p75_tn": round(float(item["p75_tn"]), 1),
                "p90_tn": round(float(item["p90_tn"]), 1),
                "p95_tn": round(float(item["p95_tn"]), 1),
                "thresholds_tn": item["thresholds_tn"],
            }
            for year, item in summary.items()
        },
        "sources": [{"label": label, "url": url} for label, url in OFFICIAL_SOURCE_LINKS],
    }
    SUMMARY_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def plot_forecast(summary: dict[int, dict[str, float | list[float]]]) -> None:
    years = sorted(SK_HYNIX_REVENUE_TN) + YEARS
    historical = [SK_HYNIX_REVENUE_TN[year] for year in sorted(SK_HYNIX_REVENUE_TN)]
    medians = historical + [summary[year]["median_tn"] for year in YEARS]

    fig, ax = plt.subplots(figsize=(11, 6))
    ax.plot(years, medians, color="#0f766e", lw=2.5, marker="o", label="Median revenue")
    ax.scatter(sorted(SK_HYNIX_REVENUE_TN), historical, color="#111827", zorder=4, label="Reported revenue")
    ax.fill_between(YEARS, [summary[y]["p10_tn"] for y in YEARS], [summary[y]["p90_tn"] for y in YEARS], color="#14b8a6", alpha=0.18, label="80% interval")
    ax.fill_between(YEARS, [summary[y]["p25_tn"] for y in YEARS], [summary[y]["p75_tn"] for y in YEARS], color="#14b8a6", alpha=0.35, label="50% interval")
    analyst_years = sorted(ANALYST_EXPECTATIONS_TN)
    analyst_centers = [
        float(np.mean(list(ANALYST_EXPECTATIONS_TN[year].values()))) for year in analyst_years
    ]
    ax.plot(
        analyst_years,
        analyst_centers,
        color="#f97316",
        ls="--",
        marker="s",
        lw=1.7,
        label="May broker forecast (before adjustment)",
    )

    ax.set_yscale("log")
    ax.set_title("SK hynix annual revenue model: history and forecast bands")
    ax.set_ylabel("Revenue (KRW tn, log scale)")
    ax.set_xlabel("Fiscal year")
    ax.grid(alpha=0.25, which="both")
    ax.legend(frameon=False)

    yticks = [20, 30, 50, 75, 100, 150, 200, 300, 500, 750]
    ax.set_yticks(yticks)
    ax.set_yticklabels([axis_label(v) for v in yticks])
    fig.tight_layout()
    fig.savefig(FORECAST_PLOT_PATH, dpi=180)
    plt.close(fig)


def plot_reference_classes(summary: dict[int, dict[str, float | list[float]]], diagnostics: dict[str, float]) -> None:
    growths = compute_growth_observations()
    fig, axes = plt.subplots(1, 2, figsize=(13, 5.5))

    for bucket, color, marker in [("sk_hynix", "#0f766e", "o"), ("micron", "#2563eb", "s")]:
        rows = growths[bucket]
        axes[0].scatter(
            [row.start_revenue for row in rows],
            [row.growth_pct for row in rows],
            color=color,
            marker=marker,
            alpha=0.80,
            label=rows[0].company,
        )
    axes[0].axhline(0, color="#111827", lw=1.0, alpha=0.55)
    axes[0].set_xscale("log")
    axes[0].set_xlabel("Starting revenue (company native currency, log scale)")
    axes[0].set_ylabel("Next-year revenue growth (%)")
    axes[0].set_title("Memory-cycle reference class")
    axes[0].grid(alpha=0.25)
    axes[0].legend(frameon=False, fontsize=9)

    median_growth_pct = []
    p10_growth_pct = []
    p90_growth_pct = []
    for year in YEARS:
        median_growth_pct.append(summary[year]["growth_p50_pct"])
        p10_growth_pct.append(summary[year]["growth_p10_pct"])
        p90_growth_pct.append(summary[year]["growth_p90_pct"])

    axes[1].plot(YEARS, median_growth_pct, color="#0f766e", marker="o", lw=2.2, label="Median modeled YoY growth")
    axes[1].fill_between(YEARS, p10_growth_pct, p90_growth_pct, color="#14b8a6", alpha=0.16, label="P10/P90 growth band")
    axes[1].axhline(LAST_FY_GROWTH_PCT, color="#111827", ls="--", lw=1.2, alpha=0.7, label="FY2025 reported YoY growth")
    axes[1].axhline(diagnostics["memory_mean_growth_pct"], color="#2563eb", ls=":", lw=2.0, label="Memory-cycle average")
    axes[1].set_title("Growth under AI 2027 production assumptions")
    axes[1].set_xlabel("Fiscal year")
    axes[1].set_ylabel("Growth (%)")
    axes[1].grid(alpha=0.25)
    axes[1].legend(frameon=False, fontsize=9)

    fig.tight_layout()
    fig.savefig(REFERENCE_PLOT_PATH, dpi=180)
    plt.close(fig)


def render_forecast_table(summary: dict[int, dict[str, float | list[float]]]) -> str:
    rows = []
    for year in YEARS:
        item = summary[year]
        rows.append(
            "<tr>"
            f"<td>FY{year}</td>"
            f"<td>{tn_label(float(item['median_tn']))}</td>"
            f"<td>{tn_label(float(item['p10_tn']))}</td>"
            f"<td>{tn_label(float(item['p90_tn']))}</td>"
            "</tr>"
        )
    return "\n".join(rows)


def write_background_html(summary: dict[int, dict[str, float | list[float]]], diagnostics: dict[str, float]) -> None:
    growths = compute_growth_observations()
    sk_obs = len(growths["sk_hynix"])
    micron_obs = len(growths["micron"])
    html = f"""<h3>Calculation of starting probabilities</h3>
<p>The starting probabilities of this market were recalculated on {AS_OF_DATE} using AI 2027's compute-production worldview, SK hynix's first-half results, memory-cycle history and a dated analyst forecast. All amounts are consolidated revenue in KRW trillions.</p>
<p><strong>Resolution check:</strong> No yearly group is ready. The contract asks for annual revenue in FY2026–FY2029. First-half revenue is evidence for the forecast, not a full-year resolution value.</p>
<h4>Latest evidence</h4>
<p>SK hynix reported Q2 revenue of KRW {SECOND_QUARTER_2026_TN:.4f}tn and first-half revenue of KRW {FIRST_HALF_2026_TN:.4f}tn. These are preliminary consolidated figures in its <a href="https://www.sec.gov/Archives/edgar/data/2120882/000119312526321989/d115239d6k.htm">July 2026 Form 6-K</a>. Holding Q2 revenue constant for both remaining quarters would imply FY2026 revenue of KRW {FIRST_HALF_2026_TN + 2 * SECOND_QUARTER_2026_TN:.1f}tn. This is a benchmark, not company guidance.</p>
<p>The <a href="https://news.skhynix.com/en/q2-2026-business-results/">Q2 release</a> reports HBM4 mass shipments beginning in Q2 and planned expansion in the second half, supporting an acceleration scenario. Pricing and capacity cycles still create substantial uncertainty.</p>
<h4>Reference classes and weights</h4>
<p>The historical sample contains {sk_obs} SK hynix and {micron_obs} Micron annual growth observations over 2015–2025. Their pooled annual log-growth standard deviation is {diagnostics['memory_sd_log_growth']:.2f}; geometric average growth is {diagnostics['memory_mean_growth_pct']:.0f}%, with average boom growth {diagnostics['memory_boom_mean_growth_pct']:.0f}% and bust growth {diagnostics['memory_bust_mean_growth_pct']:.0f}%. These are 10 overlapping year pairs, not 20 independent industry cycles. No extra precision is claimed by dividing uncertainty by the square root of 20.</p>
<h4>AI 2027 worldview and the revenue translation</h4>
<p><a href="https://ai-2027.com/research/compute-forecast">AI 2027's April 2025 compute forecast</a> separates physical production growth of about 1.65 times annually through 2027 from chip-performance improvements. It treats HBM and packaging as bottlenecks and anticipates chip-production growth slowing toward 1.25 times annually after 2027. We use those physical growth assumptions; we do not multiply SK hynix revenue by the growth in installed compute.</p>
<p>Our translation assumes an uncertain AI-linked revenue fraction averaging 75% (a Beta distribution with parameters 6 and 2), including AI-related HBM, conventional DRAM and NAND. This is an assumed revenue exposure, not a reported HBM segment share. AI-linked revenue grows with physical demand, a 3% annual price/product-mix improvement, and a 1% annual relative market-share decline. Prices and share are our assumptions, not AI 2027 predictions. Applying its post-2027 chip slowdown to memory demand is also our extension. No extra memory-per-chip multiplier is added.</p>
<p>Other memory's expected growth is partially pooled between an 8% prior and the historical geometric growth rate. The historical weight is {diagnostics['other_memory_history_weight']:.0%}, giving {diagnostics['other_memory_pooled_growth_pct']:.1f}% expected log-growth-equivalent growth. The likelihood uses 10 effective observations to allow for overlapping company cycles. The resulting latent growth rate remains uncertain.</p>
<p>For FY2026, reported H1 is fixed. The remaining two quarters start at reported Q2 revenue and grow at the quarterly equivalent of the exposure-weighted annual rate. This contributes {1-diagnostics['pooled_weight_analyst_2026']:.0%} of the log-scale pooling weight; an adjusted May broker H2 estimate contributes {diagnostics['pooled_weight_analyst_2026']:.0%}. <a href="https://img.securities.miraeasset.com/ezrlink/report/183/20260504134519223_en.pdf">Mirae Asset's May 4 report</a> projected Q2 of 82.3tn and Q3/Q4 of 101.9/118.3tn. Scaling its remaining quarters by actual Q2 divided by 82.3 gives H2 of {BROKER_H2_2026_TN:.1f}tn. This is a mechanical adjustment, not a fresh broker forecast.</p>
<p>For FY2027 the AI-production path receives {1-diagnostics['pooled_weight_analyst_2027']:.0%} weight and the mechanically adjusted May broker forecast receives {diagnostics['pooled_weight_analyst_2027']:.0%}. FY2028–2029 follow the two revenue streams directly. The former revenue-size penalty and automatic fading of the HBM contribution have been removed.</p>
<h4>Uncertainty and limitations</h4>
<p>This is a forecast conditional on the requested AI 2027 worldview, not an independently calibrated probability that the worldview occurs. Physical growth has a persistent 0.12 log-unit uncertainty; both revenue streams share a memory-cycle shock with 0.20 log-unit scale and 0.5 annual persistence. Student-t shocks with five degrees of freedom allow downturns. Near-term structural uncertainty is 0.25 log units; the dated broker uncertainty is 0.30 for H2 and 0.50 for FY2027. Pooling adds a shared 0.15 log-unit discrepancy term rather than treating demand evidence as fully independent. Translation priors and shock scales are judgemental, not backtested. Wider physical production does not guarantee higher revenue if prices or market share fall.</p>
<p>The {SIM_DRAWS:,} simulated paths retain all existing thresholds. Probabilities mean P(annual revenue ≥ threshold), decrease along each ladder, and use the default 0.5%–99.5% bounds. Preliminary H1 is treated as fixed within the simulation; the probability bounds retain some allowance for revisions and model failure. No threshold is administratively resolved by that modelling assumption.</p>
<h4>Forecast summary</h4>
<table><thead><tr><th>Fiscal year</th><th>Median</th><th>P10</th><th>P90</th></tr></thead><tbody>{render_forecast_table(summary)}</tbody></table>
<p><img src="{{{{asset:forecast_path.png}}}}" alt="SK hynix annual revenue forecast bands" width="960" /></p>
<p><img src="{{{{asset:reference_classes.png}}}}" alt="Historical memory-cycle reference classes" width="960" /></p>
<h4>Most influential historical examples</h4>
<table>
  <thead>
    <tr>
      <th>Example</th>
      <th>Revenue change</th>
      <th>Why it matters</th>
      <th>Context</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SK hynix FY2025</td>
      <td>KRW 66.2tn to KRW 97.1tn, +47%</td>
      <td>Latest completed-year anchor; HBM revenue more than doubled.</td>
      <td><a href="https://news.skhynix.com/sk-hynix-announces-fy25-financial-results/" target="_blank" rel="noopener">FY2025 results</a></td>
    </tr>
    <tr>
      <td>SK hynix 1Q26</td>
      <td>KRW 52.6tn quarterly revenue, +198% year over year</td>
      <td>Reported first-quarter revenue anchor.</td>
      <td><a href="https://news.skhynix.com/q1-2026-business-results/" target="_blank" rel="noopener">1Q26 results</a></td>
    </tr>
    <tr>
      <td>SK hynix FY2024</td>
      <td>KRW 32.8tn to KRW 66.2tn, +102%</td>
      <td>Shows how quickly memory revenue can rebound from a trough.</td>
      <td><a href="https://news.skhynix.com/sk-hynix-announces-4q24-financial-results/" target="_blank" rel="noopener">FY2024 results</a></td>
    </tr>
    <tr>
      <td>SK hynix FY2023</td>
      <td>KRW 44.6tn to KRW 32.8tn, -27%</td>
      <td>Downturn example that prevents straight-line AI extrapolation.</td>
      <td><a href="https://news.skhynix.com/sk-hynix-announces-4q24-financial-results/" target="_blank" rel="noopener">FY2024 comparative table</a></td>
    </tr>
    <tr>
      <td>Micron FY2025</td>
      <td>$25.1bn to $37.4bn, +49%</td>
      <td>Memory peer exposed to the same AI-led upcycle.</td>
      <td><a href="https://investors.micron.com/news-releases/news-release-details/micron-technology-inc-reports-results-fourth-quarter-and-full-8" target="_blank" rel="noopener">Micron FY2025 results</a></td>
    </tr>
    <tr>
      <td>Micron FY2023</td>
      <td>$30.8bn to $15.5bn, -49%</td>
      <td>Large peer-cycle bust used to keep downside tails wide.</td>
      <td><a href="https://investors.micron.com/news-releases/news-release-details/micron-technology-inc-reports-results-fourth-quarter-and-full-8" target="_blank" rel="noopener">Micron comparative figures</a></td>
    </tr>
  </tbody>
</table>


<h4>Reproducibility</h4>
<p><a href="{{{{asset:model_summary.json}}}}">Model summary and source list</a> · <a href="{{{{asset:generate_market.py}}}}">Generation script</a></p>
"""
    BACKGROUND_PATH.write_text(html, encoding="utf-8")


def write_readme() -> None:
    readme = f"""# SK hynix annual revenue market package

This folder contains the staged market inputs for:

- `data/sk-hynix-revenue/sk_hynix_revenue_market.csv`

and the supporting background assets:

- `data/sk-hynix-revenue/background_info.html`
- `data/sk-hynix-revenue/model_summary.json`
- `data/sk-hynix-revenue/forecast_path.png`
- `data/sk-hynix-revenue/reference_classes.png`

The generator is:

- `scripts/sk-hynix-revenue/generate_market.py`

Regenerate everything with:

```bash
python3 scripts/sk-hynix-revenue/generate_market.py
```

Stage the shadow draft with:

```bash
AX_TOKEN=... python3 scripts/publish_market.py \\
  --csv data/sk-hynix-revenue/sk_hynix_revenue_market.csv \\
  --background-html data/sk-hynix-revenue/background_info.html \\
  --upload data/sk-hynix-revenue/model_summary.json \\
  --upload data/sk-hynix-revenue/forecast_path.png \\
  --upload data/sk-hynix-revenue/reference_classes.png \\
  --upload scripts/sk-hynix-revenue/generate_market.py \\
  --stage-only
```
"""
    (DATA_DIR / "README.md").write_text(readme, encoding="utf-8")


def main() -> int:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    samples, diagnostics = simulate_paths()
    summary = summarize_samples(samples)
    write_market_csv(samples, summary, diagnostics)
    write_summary_json(summary, diagnostics)
    plot_forecast(summary)
    plot_reference_classes(summary, diagnostics)
    write_background_html(summary, diagnostics)
    write_readme()

    print(f"Saved market CSV: {CSV_PATH}")
    print(f"Saved model summary: {SUMMARY_PATH}")
    print(f"Saved background HTML: {BACKGROUND_PATH}")
    print(f"Saved charts: {FORECAST_PLOT_PATH.name}, {REFERENCE_PLOT_PATH.name}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
