from __future__ import print_function
import logging
import os
from .actions import ActionRunner
from .asterisk import AsteriskOutput
from .audio import AudioRenderer
from .config import area_map, area_definitions, configuration_warnings
from .models import Alert
from .sources import AlertSourceCoordinator
from .outputs import update_supermon, update_tail, switch_courtesy_tones, switch_id, send_pushover, write_status
from .phrasing import new_alert_sentence, clear_sentence, tail_sentence, description_sentence
from .state import StateStore
from .util import event_matches, isoformat, utcnow

LOG = logging.getLogger(__name__)


class Engine(object):
    def __init__(self, config):
        self.config = config
        self.areas = area_map(config)
        runtime = config["app"]["runtime_dir"]
        if not os.path.isdir(runtime):
            os.makedirs(runtime)
        self.state = StateStore(config["app"]["state_db"])
        self.sources = AlertSourceCoordinator(config)
        self.audio = AudioRenderer(config["audio"], runtime)
        self.asterisk = AsteriskOutput(config["asterisk"])
        self.actions = ActionRunner(config["actions"], self.state, self.asterisk)
        self.runtime = runtime
        self.last_cycle = None
        self.last_success = self.state.get_meta("app.last_success", None)
        self.last_primary_success = self.state.get_meta("app.last_primary_success", None)
        self.source_status = {
            "primary_source": "bayou_radar",
            "fallback_source": "nws",
            "current_source": "not_yet_polled",
            "source_by_area": {},
            "fallback_areas": [],
            "primary_successful_areas": [],
            "primary_failed_areas": [],
            "primary_healthy": None,
        }

    def enabled(self):
        override = self.state.get_meta("runtime.enabled", None)
        return self.config["app"].get("enabled", True) if override is None else bool(override)

    def set_enabled(self, value):
        self.state.set_meta("runtime.enabled", bool(value))

    def fetch_alerts(self):
        if self.config["testing"].get("inject"):
            alerts = {}
            for index, item in enumerate(self.config["testing"].get("alerts") or []):
                event_name = item.get("event") or item.get("title") or "Test"
                test_key = "testing.started.%s.%s" % (index, event_name)
                stable_start = item.get("effective") or self.state.get_meta(test_key, None)
                if not stable_start:
                    stable_start = isoformat()
                    self.state.set_meta(test_key, stable_start)
                props = {
                    "event": event_name,
                    "areaDesc": item.get("area_desc", "Configured test area"),
                    "headline": item.get("headline", "Injected Bayou Radar Skywarn test alert"),
                    "description": item.get("description", "This is an injected test alert."),
                    "instruction": item.get("instruction", "No action is required."),
                    "severity": item.get("severity", "Severe"),
                    "urgency": item.get("urgency", "Immediate"),
                    "certainty": item.get("certainty", "Observed"),
                    "status": "Actual",
                    "messageType": "Alert",
                    "effective": stable_start,
                    "onset": item.get("onset") or stable_start,
                    "expires": item.get("expires"),
                    "ends": item.get("ends"),
                }
                alert = Alert("test-%s-%s" % (index, props["event"]), props, matched_areas=item.get("areas") or list(self.areas.keys()))
                alerts[alert.id] = alert
            self.source_status = {
                "primary_source": "bayou_radar",
                "fallback_source": "nws",
                "current_source": "testing",
                "source_by_area": dict((code, "testing") for code in self.areas.keys()),
                "fallback_areas": [],
                "primary_successful_areas": sorted(self.areas.keys()),
                "primary_failed_areas": [],
                "primary_healthy": True,
            }
            return alerts, set(self.areas.keys()), set(), []
        result = self.sources.fetch(area_definitions(self.config))
        if result.fallback_areas:
            current_source = "bayou_radar_with_nws_fallback"
        elif result.successful_areas and not result.primary_failed_areas:
            current_source = "bayou_radar"
        elif result.failed_areas:
            current_source = "unavailable"
        else:
            current_source = "bayou_radar"
        self.source_status = {
            "primary_source": result.primary_source,
            "fallback_source": result.fallback_source,
            "current_source": current_source,
            "source_by_area": dict(result.source_by_area),
            "fallback_areas": sorted(result.fallback_areas),
            "primary_successful_areas": sorted(result.primary_successful_areas),
            "primary_failed_areas": sorted(result.primary_failed_areas),
            "primary_healthy": bool(result.primary_healthy),
        }
        return result.alerts, result.successful_areas, result.failed_areas, result.errors

    def filter_alerts(self, alerts):
        now = utcnow()
        include = self.config["nws"].get("include_events") or []
        exclude = self.config["nws"].get("exclude_events") or []
        basis = self.config["nws"].get("time_basis", "effective")
        result = {}
        for alert_id, alert in alerts.items():
            if alert.is_expired(now) or not alert.is_started(now, basis):
                continue
            if include and not event_matches(alert.event, include):
                continue
            if event_matches(alert.event, exclude):
                continue
            if alert.status.lower() in ("cancelled", "expired"):
                continue
            result[alert_id] = alert
        return result

    def cycle(self):
        previous = self.state.get_alerts()
        if not self.enabled():
            disabled_errors = []
            try:
                update_supermon(self.config["supermon"], {})
                update_tail(self.config["tail_message"], self.audio, {}, lambda selected: "")
                switch_courtesy_tones(self.config["courtesy_tones"], {}, forced="normal")
                switch_id(self.config["id_change"], {}, forced="normal")
            except Exception as exc:
                LOG.exception("Disabled-state cleanup failed: %s", exc)
                disabled_errors.append("Disabled-state cleanup: %s" % exc)
            self._status(previous, "disabled", [], disabled_errors)
            return {"status": "disabled", "active": len(previous)}
        incoming, successful, failed, errors = self.fetch_alerts()
        incoming = self.filter_alerts(incoming)
        # A partial source outage must not clear alerts belonging to unavailable areas.
        if failed:
            for alert_id, old in previous.items():
                if set(old.matched_areas) & set(failed):
                    incoming.setdefault(alert_id, old)
        if not successful and failed:
            # Both sources unavailable: retain non-expired cached alerts, then let expiration clear them naturally.
            incoming = self.filter_alerts(previous)
        added, updated, cleared = self._diff(previous, incoming)
        first_cycle = not bool(self.state.get_meta("app.initialized", False))
        quiet = first_cycle and self.config["app"].get("startup_quiet", True)
        if not quiet:
            self._announce_changes(added, updated, cleared, incoming)
        for alert in added:
            self.state.log("alert_added", alert)
        for alert in updated:
            self.state.log("alert_updated", alert)
        for alert in cleared:
            self.state.log("alert_cleared", alert)
        changed = self.state.replace_alerts(incoming)
        self.state.set_meta("app.initialized", True)
        self.last_cycle = isoformat()
        if successful:
            self.last_success = self.last_cycle
            self.state.set_meta("app.last_success", self.last_success)
        if self.source_status.get("primary_successful_areas"):
            self.last_primary_success = self.last_cycle
            self.state.set_meta("app.last_primary_success", self.last_primary_success)
        if errors:
            self.state.set_meta("app.last_error", "; ".join(errors))
        else:
            self.state.set_meta("app.last_error", "")
        local_errors = []
        try:
            update_supermon(self.config["supermon"], incoming)
        except Exception as exc:
            LOG.exception("Supermon output failed: %s", exc)
            local_errors.append("Supermon output: %s" % exc)
        tail_base = self.config["tail_message"].get("path", "")
        tail_missing = bool(incoming) and tail_base and not (os.path.exists(tail_base + ".ulaw") or os.path.exists(tail_base + ".txt"))
        if changed or tail_missing:
            try:
                update_tail(self.config["tail_message"], self.audio, incoming, lambda selected: tail_sentence(selected, self.areas, self.config["announcements"]))
            except Exception as exc:
                LOG.exception("Tail-message output failed: %s", exc)
                local_errors.append("Tail message: %s" % exc)
        tone_mode = None
        try:
            tone_mode = switch_courtesy_tones(self.config["courtesy_tones"], incoming)
        except Exception as exc:
            LOG.exception("Courtesy-tone switching failed: %s", exc)
            local_errors.append("Courtesy tones: %s" % exc)
        id_mode = None
        try:
            id_mode = switch_id(self.config["id_change"], incoming)
        except Exception as exc:
            LOG.exception("ID switching failed: %s", exc)
            local_errors.append("ID switching: %s" % exc)
        try:
            self.actions.evaluate(incoming)
        except Exception as exc:
            LOG.exception("Configured alert action failed: %s", exc)
            local_errors.append("Alert action: %s" % exc)
        all_errors = list(errors) + local_errors
        status_name = "degraded" if failed or local_errors or self.source_status.get("fallback_areas") else "ok"
        self._status(incoming, status_name, failed, all_errors, tone_mode, id_mode)
        return {"status": status_name, "active": len(incoming), "added": len(added), "updated": len(updated), "cleared": len(cleared), "changed": changed, "data_source": self.source_status.get("current_source"), "fallback_areas": list(self.source_status.get("fallback_areas") or [])}

    def _diff(self, previous, current):
        added = []
        updated = []
        referenced = set()
        for alert in current.values():
            referenced.update(alert.references)
            if alert.id not in previous:
                # A CAP update may replace a previous identifier and reference it.
                replaced = [old for old in previous.values() if old.id in alert.references]
                if replaced:
                    updated.append(alert)
                else:
                    added.append(alert)
            elif previous[alert.id].fingerprint != alert.fingerprint:
                updated.append(alert)
        cleared = [old for alert_id, old in previous.items() if alert_id not in current and alert_id not in referenced]
        return added, updated, cleared

    def _announcement_alerts(self, alerts):
        settings = self.config["announcements"]
        include = settings.get("include_events") or []
        exclude = settings.get("exclude_events") or []
        return [
            alert for alert in alerts
            if (not include or event_matches(alert.event, include))
            and not event_matches(alert.event, exclude)
        ]

    def _announce_changes(self, added, updated, cleared, current):
        settings = self.config["announcements"]
        selected_added = self._announcement_alerts(added)
        selected_updated = self._announcement_alerts(updated)
        selected_cleared = self._announcement_alerts(cleared)
        suffix = settings.get("suffix_audio") or []

        if settings.get("say_all_active_on_change", False) and (selected_added or selected_updated):
            active = self._announcement_alerts(list(current.values()))
            if active:
                is_update = not bool(selected_added)
                text = new_alert_sentence(active, self.areas, settings, update=is_update)
                prefix = settings.get("new_prefix_audio" if selected_added else "update_prefix_audio") or []
                self._speak(text, "active-alerts", prefix, suffix)
                self._notify("Weather alerts updated", text)
        else:
            if selected_added and settings.get("say_new", True):
                text = new_alert_sentence(selected_added, self.areas, settings)
                self._speak(text, "new-alert", settings.get("new_prefix_audio") or [], suffix)
                self._notify("New weather alert", text)
            if selected_updated and settings.get("say_updates", True):
                text = new_alert_sentence(selected_updated, self.areas, settings, update=True)
                self._speak(text, "updated-alert", settings.get("update_prefix_audio") or [], suffix)
                self._notify("Weather alert updated", text)

        if selected_cleared and settings.get("say_clear", True):
            all_clear = not current and settings.get("say_all_clear", True)
            clear_text = clear_sentence(selected_cleared, all_clear)
            self._speak(clear_text, "cleared-alert", settings.get("clear_prefix_audio") or [], suffix)
            self._notify("Weather alert cleared", clear_text)

    def _speak(self, text, name, prefix_files=None, suffix_files=None):
        try:
            audio_base = self.audio.render(text, name, prefix_files=prefix_files, suffix_files=suffix_files)
            self.asterisk.play(audio_base)
            self.state.log("announcement", detail=text)
        except Exception as exc:
            LOG.exception("Announcement failed: %s", exc)
            self.state.log("announcement_error", detail=str(exc))

    def _notify(self, title, message):
        try:
            send_pushover(self.config["notifications"]["pushover"], title, message)
        except Exception as exc:
            LOG.error("Pushover notification failed: %s", exc)

    def describe(self, selector):
        alerts = list(self.state.get_alerts().values())
        alerts.sort(key=lambda a: (a.event, a.area_desc, a.id))
        chosen = None
        try:
            index = int(selector) - 1
            if 0 <= index < len(alerts):
                chosen = alerts[index]
        except Exception:
            pass
        if chosen is None:
            lowered = str(selector).lower()
            candidates = [a for a in alerts if lowered in a.event.lower() or lowered in a.id.lower()]
            if len(candidates) == 1:
                chosen = candidates[0]
            elif len(candidates) > 1:
                raise RuntimeError("Multiple alerts match. Use the alert number or full identifier.")
        if chosen is None:
            raise RuntimeError("No active alert matches %s" % selector)
        sentence = description_sentence(chosen, self.areas, self.config["announcements"])
        self._speak(sentence, "description")
        return chosen

    def close(self):
        self.state.close()

    def _status(self, alerts, status, failed, errors, tone_mode=None, id_mode=None):
        payload = {
            "service": "Bayou Radar Skywarn",
            "version": "0.2.0",
            "status": status,
            "enabled": self.enabled(),
            "active_count": len(alerts),
            "areas": [{"code": code, "name": name} for code, name in sorted(self.areas.items())],
            "nodes": list(self.config["asterisk"].get("nodes") or []),
            "failed_areas": sorted(failed),
            "errors": list(errors),
            "configuration_warnings": configuration_warnings(self.config),
            "last_cycle": self.last_cycle,
            "last_success": self.last_success,
            "last_primary_success": self.last_primary_success,
            "primary_source": self.source_status.get("primary_source", "bayou_radar"),
            "fallback_source": self.source_status.get("fallback_source", "nws"),
            "current_source": self.source_status.get("current_source", "unknown"),
            "source_by_area": dict(self.source_status.get("source_by_area") or {}),
            "fallback_areas": list(self.source_status.get("fallback_areas") or []),
            "primary_successful_areas": list(self.source_status.get("primary_successful_areas") or []),
            "primary_failed_areas": list(self.source_status.get("primary_failed_areas") or []),
            "primary_healthy": self.source_status.get("primary_healthy"),
            "tone_mode": tone_mode,
            "id_mode": id_mode,
            "alerts": [
                {
                    "id": a.id,
                    "event": a.event,
                    "area": a.area_desc,
                    "headline": a.headline,
                    "severity": a.severity,
                    "urgency": a.urgency,
                    "certainty": a.certainty,
                    "effective": a.effective,
                    "onset": a.onset,
                    "expires": a.expires,
                    "ends": a.ends,
                    "matched_areas": list(a.matched_areas),
                }
                for a in sorted(alerts.values(), key=lambda x: (x.event, x.area_desc, x.id))
            ],
        }
        write_status(self.runtime, payload)
