"""
Airport Cyber Resilience Simulator — V2 Backend
================================================
FastAPI application with a server-authoritative simulation engine.

Key V2 upgrades over V1:
  * Airport profiling: the client picks a UK airport; all financials scale to
    that airport's economic profile (revenue/sec + regulatory fine multiplier).
  * Dynamic multiplier math engine: a 1-second asyncio loop recomputes
    Total_Loss = Operational_Cost + Regulatory_Fines + Mitigation_Costs live.
  * Crisis-communications state machine (proactive -> reactive -> blackout)
    driving a reputational-loss multiplier applied to the operational bleed.
  * 26 nodes, 9 playbooks — all data-driven from /data.

Everything remains data-driven: add airports, nodes, links or playbooks by
editing /data only.
"""

from __future__ import annotations

import asyncio
import json
import random
import time
from pathlib import Path
from typing import Any, Dict, List, Optional

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles

BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
STATIC_DIR = BASE_DIR / "static"


def load_json(name: str) -> Any:
    with (DATA_DIR / name).open("r", encoding="utf-8") as fh:
        return json.load(fh)


AIRPORTS = load_json("airports.json")
INFRASTRUCTURE = load_json("infrastructure.json")
PLAYBOOKS = load_json("playbooks.json")

AIRPORT_INDEX = {a["airport_id"]: a for a in AIRPORTS["airports"]}
PLAYBOOK_INDEX = {p["playbook_id"]: p for p in PLAYBOOKS}
NODE_INDEX = {n["id"]: n for n in INFRASTRUCTURE["nodes"]}

# --------------------------------------------------------------------------- #
# Engine tuning constants
# --------------------------------------------------------------------------- #

# Downtime penalty per operational state. Spec-mandated values for
# normal/throttled/isolated; compromised/patching are engineering additions so
# an un-mitigated breach still bleeds.
DOWNTIME = {
    "normal": 0.0, "ingress": 0.0, "compromised": 0.8, "throttled": 0.6,
    "isolated": 1.0, "patching": 0.8, "patched": 0.0,
}
CAPACITY = {
    "normal": 100, "ingress": 100, "compromised": 70, "throttled": 40,
    "isolated": 0, "patching": 70, "patched": 100,
}

PATCH_FEE = 50_000          # £ flat emergency incident-response fee
PATCH_SECONDS = 5           # patch countdown — must beat the incoming spread
THROTTLE_SLOWDOWN = 5.0     # remaining spread delay multiplier (80% slower)
SECURITY_HIT = 12           # % security integrity lost per breached node

SPREAD_JITTER = 0.22        # ±% per-run randomisation of each spread interval
CONTAINMENT_QUIET = 5.0     # s of no new compromise before "Attack Contained"
MISS_NOTIFY_PENALTY = 500_000  # £ enforcement penalty per missed statutory notification

COMMS_PROACTIVE_WINDOW = 15  # s to release a proactive statement
COMMS_REACTIVE_WINDOW = 15   # s to answer the media barrage before blackout
REP_DEFAULT = 1.0
REP_PROACTIVE = 0.5
REP_REACTIVE = 1.0
REP_BLACKOUT = 3.0


def short_money(n: float) -> str:
    n = float(n)
    if abs(n) >= 1e6:
        return f"£{n / 1e6:.1f}M"
    if abs(n) >= 1e3:
        return f"£{n / 1e3:.0f}k"
    return f"£{n:.0f}"


# --------------------------------------------------------------------------- #
# Simulation
# --------------------------------------------------------------------------- #


class Simulation:
    def __init__(self, ws: WebSocket) -> None:
        self.ws = ws
        self.airport = AIRPORT_INDEX["LHR"]      # default until configured
        self.audio = True
        self.playbook: Optional[Dict[str, Any]] = None

        self.running = False
        self.tasks: List[asyncio.Task] = []

        self.node_state: Dict[str, str] = {}
        self.node_vector: Dict[str, str] = {}
        self.chain: List[Dict[str, Any]] = []
        self.patching: Dict[str, bool] = {}      # node -> patch failed flag

        # Financials
        self.operational = 0.0
        self.regulatory = 0.0
        self.mitigation = 0.0
        self.clock = 0

        # Crisis comms
        self.rep_mult = REP_DEFAULT
        self.comms_phase = "idle"                 # idle|proactive|reactive|handled|blackout|success

        self._reset_state()

    # -- helpers ----------------------------------------------------------- #

    def _reset_state(self) -> None:
        self.node_state = {nid: "normal" for nid in NODE_INDEX}
        self.node_vector = {}
        self.chain = []
        self.patching = {}
        self.operational = self.regulatory = self.mitigation = 0.0
        self.clock = 0
        self.rep_mult = REP_DEFAULT
        self.comms_phase = "idle"
        self.last_compromise = None          # monotonic time of the last breach
        self.contained_since = None          # monotonic time containment began
        self.attack_start = None             # monotonic time the spread clock began
        self.required_notify = {"caa": False, "dft": False}
        self.notified = {"caa": False, "dft": False}
        self.settled = False                 # notification duties reconciled at end

    async def send(self, payload: Dict[str, Any]) -> None:
        try:
            await self.ws.send_text(json.dumps(payload))
        except (WebSocketDisconnect, RuntimeError):
            self.running = False

    def _breached_count(self) -> int:
        return sum(1 for s in self.node_state.values()
                   if s in ("compromised", "throttled", "isolated", "patching"))

    def _capacity(self) -> float:
        tot_w = sum(n["impact"] for n in INFRASTRUCTURE["nodes"])
        weighted = sum(CAPACITY[self.node_state[n["id"]]] * n["impact"]
                       for n in INFRASTRUCTURE["nodes"])
        return round(weighted / tot_w, 1)

    def _bleed_per_sec(self) -> float:
        rev = self.airport["base_revenue_per_sec"]
        total = 0.0
        for n in INFRASTRUCTURE["nodes"]:
            total += rev * n["impact"] * DOWNTIME[self.node_state[n["id"]]]
        return total * self.rep_mult

    async def _emit_metrics(self) -> None:
        total = self.operational + self.regulatory + self.mitigation
        await self.send({
            "type": "metrics",
            "metrics": {
                "operational": round(self.operational),
                "regulatory": round(self.regulatory),
                "mitigation": round(self.mitigation),
                "total": round(total),
                "bleed_per_sec": round(self._bleed_per_sec()),
                "reputational_multiplier": self.rep_mult,
                "security_integrity": max(0, round(100 - SECURITY_HIT * self._breached_count(), 1)),
                "operational_capacity": self._capacity(),
                "clock": self.clock,
            },
        })

    # -- lifecycle --------------------------------------------------------- #

    async def configure(self, airport_id: str, audio: bool) -> None:
        self.airport = AIRPORT_INDEX.get(airport_id, self.airport)
        self.audio = bool(audio)
        await self.stop_tasks()
        self._reset_state()
        await self.send({"type": "config_ok", "airport": self.airport, "audio": self.audio})
        await self._emit_metrics()

    async def launch(self, playbook_id: str) -> None:
        """Called by the client once its 5-second countdown reaches zero."""
        await self.stop_tasks()
        pb = PLAYBOOK_INDEX.get(playbook_id)
        if not pb:
            await self.send({"type": "error", "message": f"Unknown playbook {playbook_id}"})
            return
        self._reset_state()
        self.playbook = pb
        self.running = True

        # Build the attack chain. Each scenario has its own base tempo (fast /
        # medium / slow); on top of that we jitter every spread interval a little
        # each run, so a borderline attack sometimes beats the patch and
        # sometimes does not. Intervals stay ordered and never drop below 1.5s.
        prev = pb["ingress_point"]
        prev_orig = 0.0
        prev_delay = 0.0
        for ev in pb["spread_events"]:
            orig = float(ev["delay_seconds"])
            interval = max(0.0, orig - prev_orig)
            jittered = interval * random.uniform(1 - SPREAD_JITTER, 1 + SPREAD_JITTER)
            delay = prev_delay + max(1.5, jittered)
            self.chain.append({
                "source": prev, "target": ev["target"],
                "delay": round(delay, 1), "vector": ev["vector"],
                "fired": False, "cancelled": False,
            })
            prev_orig = orig
            prev_delay = delay
            prev = ev["target"]

        notif = pb.get("notifications", {})
        self.required_notify = {
            "caa": bool(notif.get("caa", {}).get("required")),
            "dft": bool(notif.get("dft", {}).get("required")),
        }

        await self.send({"type": "launch", "playbook": pb, "ingress_node": pb["ingress_point"]})
        await self.send({
            "type": "notify_required",
            "caa": self.required_notify["caa"],
            "dft": self.required_notify["dft"],
            "caa_reason": notif.get("caa", {}).get("reason", ""),
            "dft_reason": notif.get("dft", {}).get("reason", ""),
        })
        self.tasks = [
            asyncio.create_task(self._clock_loop()),
            asyncio.create_task(self._attack_loop()),
            asyncio.create_task(self._comms_loop()),
            asyncio.create_task(self._containment_watch()),
        ]

    async def _clock_loop(self) -> None:
        """1 Hz math engine: accrue operational bleed and stream metrics."""
        while self.running:
            await asyncio.sleep(1.0)
            if not self.running:
                break
            self.clock += 1
            self.operational += self._bleed_per_sec()
            await self.send({"type": "tick", "t": self.clock})
            await self._emit_metrics()

    async def _attack_loop(self) -> None:
        pb = self.playbook
        assert pb is not None
        ingress = pb["ingress_point"]

        await self.send({"type": "ingress", "node": ingress,
                         "method": pb["ingress_method"], "icon": pb["ingress_icon"]})
        self.node_state[ingress] = "ingress"
        await asyncio.sleep(2.0)
        await self._compromise(ingress, pb["ingress_method"], initial=True)

        start_t = time.monotonic()
        self.attack_start = start_t
        idx = 0
        while self.running and idx < len(self.chain):
            hop = self.chain[idx]
            while self.running and not hop["cancelled"]:
                if time.monotonic() - start_t >= hop["delay"]:
                    break
                await asyncio.sleep(0.1)
            if not self.running:
                return
            # A hop can only fire if its source is a live threat. If the source
            # was isolated, patched, or never actually compromised (because an
            # upstream hop was blocked), the spread is contained here.
            if hop["cancelled"] or self.node_state[hop["source"]] not in ("compromised", "throttled", "patching"):
                hop["cancelled"] = True
                await self.send({"type": "spread_blocked", "source": hop["source"], "target": hop["target"]})
                idx += 1
                continue

            # A spread tick from a patching node overrides its patch (fails it).
            if self.node_state[hop["source"]] == "patching":
                self.patching[hop["source"]] = True  # mark failed

            await self.send({"type": "spread", "source": hop["source"],
                             "target": hop["target"], "vector": hop["vector"]})
            hop["fired"] = True
            await self._compromise(hop["target"], hop["vector"])
            idx += 1

        # If mitigation blocked part of the chain, the containment watcher owns
        # the terminal state ("Attack Contained"). Only announce a clean
        # completion when the attack ran its full course unhindered.
        if self.running and not any(h["cancelled"] for h in self.chain):
            await self._settle_notifications()
            await self.send({"type": "sim_complete"})

    async def _comms_loop(self) -> None:
        """Proactive window -> reactive barrage -> blackout escalation."""
        self.comms_phase = "proactive"
        await self.send({"type": "comms_state", "phase": "proactive",
                         "message": "Proactive media window open — release a statement within 15s.",
                         "reputational_multiplier": self.rep_mult})
        await asyncio.sleep(COMMS_PROACTIVE_WINDOW)
        if not self.running or self.comms_phase in ("success", "handled"):
            return

        # Ignored -> reactive barrage.
        self.comms_phase = "reactive"
        await self.send({"type": "comms_state", "phase": "reactive",
                         "message": "Proactive window missed — journalists are calling and emailing.",
                         "reputational_multiplier": self.rep_mult})
        await asyncio.sleep(COMMS_REACTIVE_WINDOW)
        if not self.running or self.comms_phase in ("handled", "success"):
            return

        # Ignored again -> communications blackout.
        self.comms_phase = "blackout"
        self.rep_mult = REP_BLACKOUT
        await self.send({"type": "comms_state", "phase": "blackout",
                         "message": "COMMUNICATIONS BLACKOUT — public panic; reputational bleed tripled.",
                         "reputational_multiplier": self.rep_mult})
        await self._emit_metrics()

    # -- containment ------------------------------------------------------- #

    def _spread_halted(self) -> bool:
        """True when no pending hop can still compromise another node."""
        for h in self.chain:
            if (not h["fired"] and not h["cancelled"]
                    and self.node_state.get(h["source"]) in ("ingress", "compromised", "throttled", "patching")):
                return False
        return True

    async def _containment_watch(self) -> None:
        """Declare 'Attack Contained' once mitigation halts the spread and no
        new node is compromised for CONTAINMENT_QUIET seconds."""
        while self.running:
            await asyncio.sleep(0.4)
            if not self.running:
                return
            if self.last_compromise is None:
                continue
            contained = self._spread_halted() and any(h["cancelled"] for h in self.chain)
            if contained:
                if self.contained_since is None:
                    self.contained_since = time.monotonic()
                elif time.monotonic() - self.contained_since >= CONTAINMENT_QUIET:
                    await self._declare_contained()
                    return
            else:
                self.contained_since = None

    async def _declare_contained(self) -> None:
        await self._settle_notifications()
        self.running = False
        await self.send({"type": "contained", "message": "Attack Contained"})
        await self._emit_metrics()

    # -- statutory notifications ------------------------------------------- #

    async def notify(self, kind: str) -> None:
        if kind not in ("caa", "dft") or not self.playbook:
            return
        agency = "Civil Aviation Authority" if kind == "caa" else "Department for Transport"
        if not self.required_notify.get(kind):
            await self.send({"type": "notify_ack", "kind": kind, "status": "not_required",
                             "message": f"No {agency} notification duty for this incident type."})
            return
        if self.notified.get(kind):
            return
        self.notified[kind] = True
        await self.send({"type": "notify_ack", "kind": kind, "status": "done",
                         "message": f"{agency} notified — statutory reporting duty satisfied."})
        await self._emit_metrics()

    async def _settle_notifications(self) -> None:
        """Penalise any required notification the player failed to make."""
        if self.settled:
            return
        self.settled = True
        for kind in ("caa", "dft"):
            if self.required_notify.get(kind) and not self.notified.get(kind):
                agency = "CAA" if kind == "caa" else "DfT"
                fine = MISS_NOTIFY_PENALTY * self.airport["regulatory_fine_multiplier"]
                self.regulatory += fine
                await self.send({"type": "notify_missed", "kind": kind,
                                 "amount": round(fine),
                                 "message": f"Failure to notify {agency} within the statutory window — enforcement penalty."})
        await self._emit_metrics()

    async def comms(self, kind: str) -> None:
        if kind == "proactive" and self.comms_phase == "proactive":
            self.comms_phase = "success"
            self.rep_mult = REP_PROACTIVE
            await self.send({"type": "comms_state", "phase": "success",
                             "message": "Proactive statement released — reputational impact halved.",
                             "reputational_multiplier": self.rep_mult})
            await self._emit_metrics()
        elif kind == "reactive" and self.comms_phase == "reactive":
            self.comms_phase = "handled"
            self.rep_mult = REP_REACTIVE
            await self.send({"type": "comms_state", "phase": "handled",
                             "message": "Media briefed reactively — baseline reputational penalty held.",
                             "reputational_multiplier": self.rep_mult})
            await self._emit_metrics()

    # -- node events ------------------------------------------------------- #

    async def _compromise(self, node: str, vector: str, initial: bool = False) -> None:
        if self.node_state.get(node) in ("compromised", "isolated", "throttled", "patched", "patching"):
            return
        self.node_state[node] = "compromised"
        self.node_vector[node] = vector
        self.last_compromise = time.monotonic()
        self.contained_since = None
        await self.send({"type": "compromised", "node": node, "vector": vector,
                         "initial": initial, "compliance": NODE_INDEX[node].get("compliance")})

        meta = NODE_INDEX[node]
        if meta.get("data_sensitive"):
            fine = meta["flat_fine"] * self.airport["regulatory_fine_multiplier"]
            self.regulatory += fine
            await self.send({"type": "fine", "node": node, "amount": round(fine),
                             "label": f"-{short_money(fine)} Regulatory Penalty"})
        await self._emit_metrics()

    # -- mitigations ------------------------------------------------------- #

    async def mitigate(self, node: str, option: str) -> None:
        if node not in self.node_state:
            return
        option = option.upper()

        if option == "ISOLATE":
            self.node_state[node] = "isolated"
            for hop in self.chain:
                if hop["source"] == node and not hop["fired"]:
                    hop["cancelled"] = True
            await self.send({"type": "mitigation", "node": node, "option": "ISOLATE",
                             "message": f"{NODE_INDEX[node]['name']} isolated — links severed, spread halted, capacity 0%."})

        elif option == "THROTTLE":
            self.node_state[node] = "throttled"
            for hop in self.chain:
                if hop["source"] == node and not hop["fired"] and not hop["cancelled"]:
                    hop["delay"] *= THROTTLE_SLOWDOWN
            await self.send({"type": "mitigation", "node": node, "option": "THROTTLE",
                             "message": f"{NODE_INDEX[node]['name']} throttled — spread slowed 80%, capacity 40%."})

        elif option == "STOP":
            self.mitigation += PATCH_FEE
            self.patching[node] = False
            self.node_state[node] = "patching"
            # Work out where the attack is heading from this node, and how long
            # until it arrives — so the client can show the spread closing in on
            # the next node while the patch counts down.
            approach_target = None
            approach_seconds = None
            for hop in self.chain:
                if hop["source"] == node and not hop["fired"] and not hop["cancelled"]:
                    approach_target = hop["target"]
                    if self.attack_start is not None:
                        approach_seconds = round(max(0.2, hop["delay"] - (time.monotonic() - self.attack_start)), 1)
                    break
            eta = f" — attack reaches {NODE_INDEX[approach_target]['name']} in {approach_seconds}s" if approach_target else ""
            await self.send({"type": "patch_start", "node": node, "seconds": PATCH_SECONDS,
                             "target": approach_target, "approach_seconds": approach_seconds,
                             "message": f"Emergency patch on {NODE_INDEX[node]['name']} (£50k) — {PATCH_SECONDS}s{eta}."})
            self.tasks.append(asyncio.create_task(self._patch(node)))
        else:
            return

        await self._emit_metrics()

    async def _patch(self, node: str) -> None:
        end = time.monotonic() + PATCH_SECONDS
        while self.running and time.monotonic() < end:
            if self.patching.get(node):  # overridden by a spread tick
                break
            await asyncio.sleep(0.1)
        if not self.running:
            return
        failed = self.patching.get(node, False)
        if failed:
            self.node_state[node] = "compromised"
            await self.send({"type": "patch_complete", "node": node, "success": False})
        else:
            self.node_state[node] = "patched"
            for hop in self.chain:
                if hop["source"] == node and not hop["fired"]:
                    hop["cancelled"] = True
            await self.send({"type": "patch_complete", "node": node, "success": True})
        await self._emit_metrics()

    # -- teardown ---------------------------------------------------------- #

    async def stop_tasks(self) -> None:
        self.running = False
        for t in self.tasks:
            if not t.done():
                t.cancel()
        for t in self.tasks:
            try:
                await t
            except asyncio.CancelledError:
                pass
        self.tasks = []

    async def reset(self) -> None:
        await self.stop_tasks()
        self._reset_state()
        await self.send({"type": "reset"})
        await self._emit_metrics()


# --------------------------------------------------------------------------- #
# FastAPI wiring
# --------------------------------------------------------------------------- #

app = FastAPI(title="Airport Cyber Resilience Simulator V2")


@app.get("/api/bootstrap")
async def api_bootstrap() -> JSONResponse:
    return JSONResponse({
        "airports": AIRPORTS,
        "infrastructure": INFRASTRUCTURE,
        "playbooks": PLAYBOOKS,
    })


@app.get("/api/airports")
async def api_airports() -> JSONResponse:
    return JSONResponse(AIRPORTS)


@app.get("/api/infrastructure")
async def api_infrastructure() -> JSONResponse:
    return JSONResponse(INFRASTRUCTURE)


@app.get("/api/playbooks")
async def api_playbooks() -> JSONResponse:
    return JSONResponse(PLAYBOOKS)


@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket) -> None:
    await ws.accept()
    sim = Simulation(ws)
    await sim._emit_metrics()
    try:
        while True:
            msg = json.loads(await ws.receive_text())
            action = msg.get("action")
            if action == "configure":
                await sim.configure(msg.get("airport_id", "LHR"), msg.get("audio", True))
            elif action == "launch":
                await sim.launch(msg.get("playbook_id", ""))
            elif action == "mitigate":
                await sim.mitigate(msg.get("node", ""), msg.get("option", ""))
            elif action == "comms":
                await sim.comms(msg.get("kind", ""))
            elif action == "notify":
                await sim.notify(msg.get("kind", ""))
            elif action == "reset":
                await sim.reset()
            elif action == "ping":
                await sim.send({"type": "pong"})
    except WebSocketDisconnect:
        await sim.stop_tasks()
    except Exception:
        await sim.stop_tasks()


app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
