//! In-memory SCADA store with seeded data and engines.
//!
//! Thread-safe via per-collection `Mutex`. IDs come from a monotonic sequence
//! (`PREFIX-{n}` from 1000). Every state change appends to an audit trail.
//!
//! SAFETY-CRITICAL: `issue_command` runs a chain of interlocks before any value
//! is applied to a control/setpoint point. A rejected command is recorded but
//! NEVER mutates the point. This is a reference engine — a production deployment
//! must additionally enforce these interlocks at the RTU/PLC and via hardware.

use crate::types::*;
use chrono::{Duration, NaiveDate, Utc};
use std::collections::HashMap;
use std::sync::Mutex;

pub struct ScadaStore {
    sites: Mutex<HashMap<String, Site>>,
    assets: Mutex<HashMap<String, Asset>>,
    tags: Mutex<HashMap<String, Tag>>,
    history: Mutex<Vec<Reading>>,
    alarms: Mutex<HashMap<String, Alarm>>,
    commands: Mutex<Vec<ControlCommand>>,
    outages: Mutex<HashMap<String, Outage>>,
    work_orders: Mutex<HashMap<String, WorkOrder>>,
    audit_log: Mutex<Vec<AuditEntry>>,
    seq: Mutex<u64>,
}

impl Default for ScadaStore {
    fn default() -> Self {
        Self::new()
    }
}

impl ScadaStore {
    pub fn new() -> Self {
        let s = ScadaStore {
            sites: Mutex::new(HashMap::new()),
            assets: Mutex::new(HashMap::new()),
            tags: Mutex::new(HashMap::new()),
            history: Mutex::new(Vec::new()),
            alarms: Mutex::new(HashMap::new()),
            commands: Mutex::new(Vec::new()),
            outages: Mutex::new(HashMap::new()),
            work_orders: Mutex::new(HashMap::new()),
            audit_log: Mutex::new(Vec::new()),
            seq: Mutex::new(1000),
        };
        s.seed();
        s
    }

    fn next(&self, prefix: &str) -> String {
        let mut n = self.seq.lock().unwrap();
        *n += 1;
        format!("{prefix}-{n}")
    }

    fn audit(&self, actor: &str, action: &str, detail: impl Into<String>) {
        self.audit_log.lock().unwrap().push(AuditEntry {
            at: Utc::now(),
            actor: actor.to_string(),
            action: action.to_string(),
            detail: detail.into(),
        });
    }

    // ─── sites & assets ────────────────────────────────────────────────────

    pub fn create_site(&self, name: &str, sector: Sector, region: &str, actor: &str) -> Site {
        let s = Site { id: self.next("SITE"), name: name.to_string(), sector, region: region.to_string(), created_at: Utc::now() };
        self.sites.lock().unwrap().insert(s.id.clone(), s.clone());
        self.audit(actor, "create_site", s.id.clone());
        s
    }

    pub fn list_sites(&self, sector: Option<Sector>) -> Vec<Site> {
        let mut v: Vec<Site> = self.sites.lock().unwrap().values().filter(|s| sector.is_none_or(|x| s.sector == x)).cloned().collect();
        v.sort_by(|a, b| a.name.cmp(&b.name));
        v
    }

    pub fn create_asset(&self, site_id: &str, name: &str, kind: &str, actor: &str) -> Result<Asset, String> {
        if !self.sites.lock().unwrap().contains_key(site_id) { return Err(format!("Site not found: {site_id}")); }
        let a = Asset { id: self.next("AST"), site_id: site_id.to_string(), name: name.to_string(), kind: kind.to_string(), state: AssetState::InService, lockout: false, commissioned_on: None, created_at: Utc::now() };
        self.assets.lock().unwrap().insert(a.id.clone(), a.clone());
        self.audit(actor, "create_asset", a.id.clone());
        Ok(a)
    }

    pub fn get_asset(&self, id: &str) -> Option<Asset> {
        self.assets.lock().unwrap().get(id).cloned()
    }

    pub fn list_assets(&self, site_id: Option<&str>, kind: Option<&str>, state: Option<AssetState>) -> Vec<Asset> {
        let mut v: Vec<Asset> = self.assets.lock().unwrap().values()
            .filter(|a| site_id.is_none_or(|s| a.site_id == s))
            .filter(|a| kind.is_none_or(|k| a.kind.eq_ignore_ascii_case(k)))
            .filter(|a| state.is_none_or(|st| a.state == st))
            .cloned().collect();
        v.sort_by(|a, b| a.name.cmp(&b.name));
        v
    }

    pub fn set_asset_state(&self, id: &str, state: AssetState, actor: &str) -> Result<Asset, String> {
        let mut assets = self.assets.lock().unwrap();
        let a = assets.get_mut(id).ok_or_else(|| format!("Asset not found: {id}"))?;
        a.state = state;
        let out = a.clone();
        drop(assets);
        self.audit(actor, "set_asset_state", format!("{id} -> {state:?}"));
        Ok(out)
    }

    /// Apply or release a lockout/tagout hold. While locked, control is blocked.
    pub fn set_lockout(&self, id: &str, lockout: bool, actor: &str) -> Result<Asset, String> {
        let mut assets = self.assets.lock().unwrap();
        let a = assets.get_mut(id).ok_or_else(|| format!("Asset not found: {id}"))?;
        a.lockout = lockout;
        if lockout { a.state = AssetState::LockedOut; }
        else if a.state == AssetState::LockedOut { a.state = AssetState::OutOfService; }
        let out = a.clone();
        drop(assets);
        self.audit(actor, if lockout { "lockout_apply" } else { "lockout_release" }, id.to_string());
        Ok(out)
    }

    // ─── tags / points ───────────────────────────────────────────────────

    #[allow(clippy::too_many_arguments)]
    pub fn create_tag(&self, asset_id: &str, name: &str, point_type: PointType, unit: &str, min: Option<f64>, max: Option<f64>, warn_high: Option<f64>, crit_high: Option<f64>, warn_low: Option<f64>, crit_low: Option<f64>, value: f64, actor: &str) -> Result<Tag, String> {
        if !self.assets.lock().unwrap().contains_key(asset_id) { return Err(format!("Asset not found: {asset_id}")); }
        let t = Tag { id: self.next("TAG"), asset_id: asset_id.to_string(), name: name.to_string(), point_type, unit: unit.to_string(), min, max, warn_high, crit_high, warn_low, crit_low, value, quality: "good".into(), updated_at: Utc::now() };
        self.tags.lock().unwrap().insert(t.id.clone(), t.clone());
        self.audit(actor, "create_tag", t.id.clone());
        Ok(t)
    }

    pub fn get_tag(&self, id: &str) -> Option<Tag> {
        self.tags.lock().unwrap().get(id).cloned()
    }

    pub fn list_tags(&self, asset_id: &str) -> Vec<Tag> {
        let mut v: Vec<Tag> = self.tags.lock().unwrap().values().filter(|t| t.asset_id == asset_id).cloned().collect();
        v.sort_by(|a, b| a.name.cmp(&b.name));
        v
    }

    /// Ingest a telemetry reading for a point (field/simulator update). Records
    /// history and auto-raises/clears threshold alarms. Not a control action.
    pub fn ingest_reading(&self, tag_id: &str, value: f64, quality: &str, actor: &str) -> Result<Tag, String> {
        let mut tags = self.tags.lock().unwrap();
        let t = tags.get_mut(tag_id).ok_or_else(|| format!("Tag not found: {tag_id}"))?;
        t.value = value;
        t.quality = quality.to_string();
        t.updated_at = Utc::now();
        let snapshot = t.clone();
        drop(tags);
        self.history.lock().unwrap().push(Reading { tag_id: tag_id.to_string(), value, quality: quality.to_string(), at: Utc::now() });
        self.evaluate_thresholds(&snapshot, actor);
        self.audit(actor, "ingest_reading", format!("{tag_id} = {value}"));
        Ok(snapshot)
    }

    /// Historian: readings for a tag, most recent first (optionally limited).
    pub fn tag_history(&self, tag_id: &str, limit: usize) -> Vec<Reading> {
        let hist = self.history.lock().unwrap();
        let mut v: Vec<Reading> = hist.iter().filter(|r| r.tag_id == tag_id).cloned().collect();
        v.sort_by(|a, b| b.at.cmp(&a.at));
        v.truncate(limit);
        v
    }

    /// Simple trend over a tag's history: count, min, max, mean, and slope sign.
    pub fn tag_trend(&self, tag_id: &str) -> Option<serde_json::Value> {
        let t = self.get_tag(tag_id)?;
        let hist = self.history.lock().unwrap();
        let mut series: Vec<&Reading> = hist.iter().filter(|r| r.tag_id == tag_id).collect();
        series.sort_by(|a, b| a.at.cmp(&b.at));
        let vals: Vec<f64> = series.iter().map(|r| r.value).collect();
        if vals.is_empty() { return Some(serde_json::json!({"tag_id": tag_id, "samples": 0})); }
        let n = vals.len() as f64;
        let mean = vals.iter().sum::<f64>() / n;
        let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
        let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
        let trend = if vals.len() >= 2 {
            let d = vals.last().unwrap() - vals.first().unwrap();
            if d > 0.0 { "rising" } else if d < 0.0 { "falling" } else { "flat" }
        } else { "flat" };
        Some(serde_json::json!({
            "tag_id": tag_id, "name": t.name, "unit": t.unit, "samples": vals.len(),
            "current": t.value, "min": min, "max": max,
            "mean": (mean*1000.0).round()/1000.0, "trend": trend,
        }))
    }

    /// Threshold evaluation: raise/clear alarms based on a tag's crit/warn bounds.
    fn evaluate_thresholds(&self, t: &Tag, actor: &str) {
        let sev = if t.crit_high.is_some_and(|x| t.value >= x) || t.crit_low.is_some_and(|x| t.value <= x) {
            Some(AlarmSeverity::Critical)
        } else if t.warn_high.is_some_and(|x| t.value >= x) || t.warn_low.is_some_and(|x| t.value <= x) {
            Some(AlarmSeverity::Warning)
        } else {
            None
        };
        match sev {
            Some(severity) => {
                // Avoid duplicate active alarms for the same tag.
                let exists = self.alarms.lock().unwrap().values().any(|a| a.tag_id.as_deref() == Some(&t.id) && a.state != AlarmState::Cleared);
                if !exists {
                    let id = self.next("ALM");
                    let alarm = Alarm { id: id.clone(), asset_id: t.asset_id.clone(), tag_id: Some(t.id.clone()), severity, state: AlarmState::Active, message: format!("{} {} threshold breach ({} {})", t.name, format!("{severity:?}").to_lowercase(), t.value, t.unit), value: Some(t.value), raised_at: Utc::now(), acknowledged_at: None, acknowledged_by: None, cleared_at: None };
                    self.alarms.lock().unwrap().insert(id.clone(), alarm);
                    self.audit(actor, "alarm_raised", format!("{id} {severity:?}"));
                }
            }
            None => {
                // Back in band → clear any active alarm on this tag.
                let mut alarms = self.alarms.lock().unwrap();
                for a in alarms.values_mut().filter(|a| a.tag_id.as_deref() == Some(&t.id) && a.state != AlarmState::Cleared) {
                    a.state = AlarmState::Cleared;
                    a.cleared_at = Some(Utc::now());
                }
            }
        }
    }

    // ─── alarms ────────────────────────────────────────────────────────────

    pub fn list_alarms(&self, active_only: bool, severity: Option<AlarmSeverity>) -> Vec<Alarm> {
        let mut v: Vec<Alarm> = self.alarms.lock().unwrap().values()
            .filter(|a| !active_only || a.state != AlarmState::Cleared)
            .filter(|a| severity.is_none_or(|s| a.severity == s))
            .cloned().collect();
        v.sort_by(|a, b| b.raised_at.cmp(&a.raised_at));
        v
    }

    pub fn raise_alarm(&self, asset_id: &str, severity: AlarmSeverity, message: &str, actor: &str) -> Result<Alarm, String> {
        if !self.assets.lock().unwrap().contains_key(asset_id) { return Err(format!("Asset not found: {asset_id}")); }
        let id = self.next("ALM");
        let a = Alarm { id: id.clone(), asset_id: asset_id.to_string(), tag_id: None, severity, state: AlarmState::Active, message: message.to_string(), value: None, raised_at: Utc::now(), acknowledged_at: None, acknowledged_by: None, cleared_at: None };
        self.alarms.lock().unwrap().insert(id.clone(), a.clone());
        self.audit(actor, "raise_alarm", format!("{id} {severity:?}"));
        Ok(a)
    }

    pub fn acknowledge_alarm(&self, alarm_id: &str, actor: &str) -> Result<Alarm, String> {
        let mut alarms = self.alarms.lock().unwrap();
        let a = alarms.get_mut(alarm_id).ok_or_else(|| format!("Alarm not found: {alarm_id}"))?;
        if a.state == AlarmState::Active {
            a.state = AlarmState::Acknowledged;
            a.acknowledged_at = Some(Utc::now());
            a.acknowledged_by = Some(actor.to_string());
        }
        let out = a.clone();
        drop(alarms);
        self.audit(actor, "acknowledge_alarm", alarm_id.to_string());
        Ok(out)
    }

    pub fn clear_alarm(&self, alarm_id: &str, actor: &str) -> Result<Alarm, String> {
        let mut alarms = self.alarms.lock().unwrap();
        let a = alarms.get_mut(alarm_id).ok_or_else(|| format!("Alarm not found: {alarm_id}"))?;
        a.state = AlarmState::Cleared;
        a.cleared_at = Some(Utc::now());
        let out = a.clone();
        drop(alarms);
        self.audit(actor, "clear_alarm", alarm_id.to_string());
        Ok(out)
    }

    // ─── control commands (SAFETY-CRITICAL) ────────────────────────────────

    /// Issue a control command to a writable point. Runs the interlock chain;
    /// only an all-pass command mutates the point. Always records a command row.
    pub fn issue_command(&self, tag_id: &str, action: &str, value: f64, issued_by: &str) -> Result<ControlCommand, String> {
        let tag = self.get_tag(tag_id).ok_or_else(|| format!("Tag not found: {tag_id}"))?;
        let asset = self.get_asset(&tag.asset_id).ok_or("asset missing")?;
        let mut checked: Vec<String> = Vec::new();
        let mut rejection: Option<String> = None;

        // Interlock 1: point must be writable.
        checked.push("writable_point".into());
        if !matches!(tag.point_type, PointType::Control | PointType::Setpoint) {
            rejection = Some(format!("point {} is {:?}, not writable", tag.name, tag.point_type));
        }

        // Interlock 2: lockout/tagout.
        if rejection.is_none() {
            checked.push("lockout_tagout".into());
            if asset.lockout || asset.state == AssetState::LockedOut {
                rejection = Some(format!("asset {} is locked out (LOTO)", asset.id));
            }
        }

        // Interlock 3: asset must not be faulted.
        if rejection.is_none() {
            checked.push("asset_not_faulted".into());
            if asset.state == AssetState::Faulted {
                rejection = Some(format!("asset {} is faulted; clear fault before control", asset.id));
            }
        }

        // Interlock 4: no active CRITICAL alarm on the asset.
        if rejection.is_none() {
            checked.push("no_active_critical_alarm".into());
            let crit = self.alarms.lock().unwrap().values().any(|a| a.asset_id == asset.id && a.severity == AlarmSeverity::Critical && a.state != AlarmState::Cleared);
            if crit {
                rejection = Some(format!("asset {} has an active critical alarm; resolve/ack before control", asset.id));
            }
        }

        // Interlock 5: setpoint within engineering range.
        if rejection.is_none() && tag.point_type == PointType::Setpoint {
            checked.push("setpoint_in_range".into());
            if let Some(min) = tag.min { if value < min { rejection = Some(format!("setpoint {value} below min {min}")); } }
            if rejection.is_none() { if let Some(max) = tag.max { if value > max { rejection = Some(format!("setpoint {value} above max {max}")); } } }
        }

        // Interlock 6: control points accept only 0/1.
        if rejection.is_none() && tag.point_type == PointType::Control {
            checked.push("binary_control_value".into());
            if value != 0.0 && value != 1.0 {
                rejection = Some(format!("control point expects 0 or 1, got {value}"));
            }
        }

        let status = if rejection.is_some() { CommandStatus::Rejected } else { CommandStatus::Executed };
        let cmd = ControlCommand {
            id: self.next("CMD"),
            tag_id: tag_id.to_string(),
            asset_id: asset.id.clone(),
            action: action.to_string(),
            requested_value: value,
            status,
            interlocks_checked: checked,
            rejection_reason: rejection.clone(),
            issued_by: issued_by.to_string(),
            at: Utc::now(),
        };
        self.commands.lock().unwrap().push(cmd.clone());

        // Apply ONLY on pass.
        if status == CommandStatus::Executed {
            let mut tags = self.tags.lock().unwrap();
            if let Some(t) = tags.get_mut(tag_id) {
                t.value = value;
                t.updated_at = Utc::now();
            }
            drop(tags);
            self.audit(issued_by, "command_executed", format!("{} {action}={value}", cmd.id));
        } else {
            self.audit(issued_by, "command_rejected", format!("{}: {}", cmd.id, rejection.unwrap_or_default()));
        }
        Ok(cmd)
    }

    /// Dry-run: evaluate interlocks for a command WITHOUT applying it. Lets agents
    /// check feasibility before requesting an approval-gated execution.
    pub fn check_interlocks(&self, tag_id: &str, value: f64) -> Result<serde_json::Value, String> {
        let tag = self.get_tag(tag_id).ok_or_else(|| format!("Tag not found: {tag_id}"))?;
        let asset = self.get_asset(&tag.asset_id).ok_or("asset missing")?;
        let mut checks: Vec<serde_json::Value> = Vec::new();
        let mut add = |name: &str, pass: bool, detail: &str| checks.push(serde_json::json!({"interlock": name, "pass": pass, "detail": detail}));

        let writable = matches!(tag.point_type, PointType::Control | PointType::Setpoint);
        add("writable_point", writable, &format!("{:?}", tag.point_type));
        add("lockout_tagout", !(asset.lockout || asset.state == AssetState::LockedOut), if asset.lockout { "locked out" } else { "ok" });
        add("asset_not_faulted", asset.state != AssetState::Faulted, &format!("{:?}", asset.state));
        let crit = self.alarms.lock().unwrap().values().any(|a| a.asset_id == asset.id && a.severity == AlarmSeverity::Critical && a.state != AlarmState::Cleared);
        add("no_active_critical_alarm", !crit, if crit { "active critical alarm" } else { "none" });
        if tag.point_type == PointType::Setpoint {
            let in_range = tag.min.is_none_or(|m| value >= m) && tag.max.is_none_or(|m| value <= m);
            add("setpoint_in_range", in_range, &format!("[{:?}, {:?}]", tag.min, tag.max));
        }
        if tag.point_type == PointType::Control {
            add("binary_control_value", value == 0.0 || value == 1.0, "expects 0 or 1");
        }
        let all_pass = checks.iter().all(|c| c["pass"].as_bool().unwrap_or(false));
        Ok(serde_json::json!({"tag_id": tag_id, "asset_id": asset.id, "value": value, "would_execute": all_pass, "interlocks": checks}))
    }

    pub fn command_history(&self, asset_id: Option<&str>, limit: usize) -> Vec<ControlCommand> {
        let cmds = self.commands.lock().unwrap();
        let mut v: Vec<ControlCommand> = cmds.iter().filter(|c| asset_id.is_none_or(|a| c.asset_id == a)).cloned().collect();
        v.sort_by(|a, b| b.at.cmp(&a.at));
        v.truncate(limit);
        v
    }

    // ─── outages ───────────────────────────────────────────────────────────

    /// Report an outage; triage priority is computed from safety hazard +
    /// customers affected (P1 highest). Powers the Grid Outage Triage agent.
    pub fn report_outage(&self, site_id: &str, asset_id: Option<String>, cause: &str, customers_affected: u32, safety_hazard: bool, actor: &str) -> Result<Outage, String> {
        if !self.sites.lock().unwrap().contains_key(site_id) { return Err(format!("Site not found: {site_id}")); }
        let priority = triage_priority(safety_hazard, customers_affected);
        let o = Outage {
            id: self.next("OUT"),
            site_id: site_id.to_string(),
            asset_id,
            cause: cause.to_string(),
            status: OutageStatus::Reported,
            priority: priority.clone(),
            customers_affected,
            safety_hazard,
            reported_at: Utc::now(),
            restored_at: None,
        };
        self.outages.lock().unwrap().insert(o.id.clone(), o.clone());
        self.audit(actor, "report_outage", format!("{} {priority}", o.id));
        Ok(o)
    }

    pub fn update_outage_status(&self, outage_id: &str, status: OutageStatus, actor: &str) -> Result<Outage, String> {
        let mut outages = self.outages.lock().unwrap();
        let o = outages.get_mut(outage_id).ok_or_else(|| format!("Outage not found: {outage_id}"))?;
        o.status = status;
        if status == OutageStatus::Restored { o.restored_at = Some(Utc::now()); }
        let out = o.clone();
        drop(outages);
        self.audit(actor, "update_outage_status", format!("{outage_id} -> {status:?}"));
        Ok(out)
    }

    /// Active outages, highest priority (P1) first — the triage queue.
    pub fn outage_queue(&self) -> Vec<Outage> {
        let mut v: Vec<Outage> = self.outages.lock().unwrap().values().filter(|o| o.status != OutageStatus::Restored).cloned().collect();
        v.sort_by(|a, b| a.priority.cmp(&b.priority).then(b.customers_affected.cmp(&a.customers_affected)));
        v
    }

    // ─── maintenance ─────────────────────────────────────────────────────

    pub fn create_work_order(&self, asset_id: &str, kind: &str, priority: &str, description: &str, scheduled_for: Option<NaiveDate>, actor: &str) -> Result<WorkOrder, String> {
        if !self.assets.lock().unwrap().contains_key(asset_id) { return Err(format!("Asset not found: {asset_id}")); }
        let status = if scheduled_for.is_some() { WorkOrderStatus::Scheduled } else { WorkOrderStatus::Open };
        let w = WorkOrder { id: self.next("WO"), asset_id: asset_id.to_string(), kind: kind.to_string(), priority: priority.to_string(), status, description: description.to_string(), scheduled_for, assigned_to: None, created_at: Utc::now(), completed_at: None };
        self.work_orders.lock().unwrap().insert(w.id.clone(), w.clone());
        self.audit(actor, "create_work_order", w.id.clone());
        Ok(w)
    }

    pub fn update_work_order(&self, wo_id: &str, status: Option<WorkOrderStatus>, assigned_to: Option<String>, scheduled_for: Option<NaiveDate>, actor: &str) -> Result<WorkOrder, String> {
        let mut wos = self.work_orders.lock().unwrap();
        let w = wos.get_mut(wo_id).ok_or_else(|| format!("Work order not found: {wo_id}"))?;
        if let Some(s) = status {
            w.status = s;
            if s == WorkOrderStatus::Completed { w.completed_at = Some(Utc::now()); }
        }
        if assigned_to.is_some() { w.assigned_to = assigned_to; }
        if scheduled_for.is_some() { w.scheduled_for = scheduled_for; }
        let out = w.clone();
        drop(wos);
        self.audit(actor, "update_work_order", format!("{wo_id}"));
        Ok(out)
    }

    pub fn list_work_orders(&self, asset_id: Option<&str>, status: Option<WorkOrderStatus>) -> Vec<WorkOrder> {
        let mut v: Vec<WorkOrder> = self.work_orders.lock().unwrap().values()
            .filter(|w| asset_id.is_none_or(|a| w.asset_id == a))
            .filter(|w| status.is_none_or(|s| w.status == s))
            .cloned().collect();
        v.sort_by(|a, b| b.created_at.cmp(&a.created_at));
        v
    }

    pub fn audit_log(&self, limit: usize) -> Vec<AuditEntry> {
        let log = self.audit_log.lock().unwrap();
        log.iter().rev().take(limit).cloned().collect()
    }

    // ─── seed ────────────────────────────────────────────────────────────

    fn seed(&self) {
        let today = Utc::now().date_naive();

        // Electric substation with a breaker (control) + feeder load (analog).
        let sub = self.create_site("Northgate Substation", Sector::Electric, "North", "system");
        let bkr = self.create_asset(&sub.id, "Breaker BKR-12", "breaker", "system").unwrap();
        self.create_tag(&bkr.id, "BKR_POS", PointType::Control, "state", None, None, None, None, None, None, 1.0, "system").unwrap();
        let feeder = self.create_asset(&sub.id, "Feeder F-3", "feeder", "system").unwrap();
        self.create_tag(&feeder.id, "FEEDER_LOAD", PointType::Analog, "A", Some(0.0), Some(600.0), Some(500.0), Some(580.0), None, None, 420.0, "system").unwrap();

        // Gas regulator station with a pressure point (analog) + setpoint.
        let gas = self.create_site("Maple Gas Regulator", Sector::Gas, "East", "system");
        let reg = self.create_asset(&gas.id, "Regulator RG-7", "regulator", "system").unwrap();
        self.create_tag(&reg.id, "GAS_PRESSURE_PSI", PointType::Analog, "psi", Some(0.0), Some(60.0), Some(45.0), Some(55.0), Some(10.0), Some(5.0), 38.0, "system").unwrap();
        self.create_tag(&reg.id, "PRESSURE_SETPOINT", PointType::Setpoint, "psi", Some(20.0), Some(50.0), None, None, None, None, 40.0, "system").unwrap();

        // Water pump station with a pump (control) + flow (analog).
        let water = self.create_site("Riverside Pump Station", Sector::Water, "West", "system");
        let pump = self.create_asset(&water.id, "Pump P-2", "pump", "system").unwrap();
        self.create_tag(&pump.id, "PUMP_RUN", PointType::Control, "state", None, None, None, None, None, None, 1.0, "system").unwrap();
        self.create_tag(&pump.id, "FLOW_GPM", PointType::Analog, "gpm", Some(0.0), Some(2000.0), Some(1800.0), Some(1950.0), Some(200.0), Some(100.0), 1200.0, "system").unwrap();

        // Renewable solar site with an inverter (setpoint for curtailment).
        let solar = self.create_site("Sunfield Solar Array", Sector::Renewable, "South", "system");
        let inv = self.create_asset(&solar.id, "Inverter INV-1", "inverter", "system").unwrap();
        self.create_tag(&inv.id, "ACTIVE_POWER_KW", PointType::Analog, "kW", Some(0.0), Some(2500.0), None, None, None, None, 1800.0, "system").unwrap();
        self.create_tag(&inv.id, "POWER_SETPOINT_KW", PointType::Setpoint, "kW", Some(0.0), Some(2500.0), None, None, None, None, 2500.0, "system").unwrap();

        // A preventive work order due on the breaker.
        self.create_work_order(&bkr.id, "preventive", "medium", "Annual breaker inspection", Some(today + Duration::days(14)), "system").ok();
    }
}

// ─── triage helper ───────────────────────────────────────────────────────

/// Outage priority: safety hazards are always P1; otherwise by customers.
fn triage_priority(safety_hazard: bool, customers: u32) -> String {
    if safety_hazard { return "P1".into(); }
    match customers {
        c if c >= 1000 => "P1".into(),
        c if c >= 100 => "P2".into(),
        c if c >= 10 => "P3".into(),
        _ => "P4".into(),
    }
}
