# retoor <retoor@molodetz.nl>

import asyncio
import json
import time

from devplacepy import config
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.containers import api, store
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.tasks.schedule import next_run, now_utc, to_iso
from devplacepy.services.audit import record as audit

AUTO_RESTART = ("always", "on-failure", "unless-stopped")
SYNC_EVERY_SECONDS = 60


def _set_status(inst, changes: dict, *, actor_kind: str = "service", reason: str = "") -> dict:
    uid = inst["uid"]
    old_status = inst.get("status")
    new_status = changes.get("status", old_status)
    store.update_instance(uid, changes)
    inst.update(changes)
    if new_status != old_status:
        detail = {"from": old_status, "to": new_status}
        if reason:
            detail["reason"] = reason
        store.record_event(inst, "status_change", actor_kind, "", detail)
        audit.record_system(
            "container.instance.status",
            actor_kind="service",
            origin="scheduler",
            target_type="instance",
            target_uid=uid,
            target_label=inst.get("name"),
            old_value=old_status,
            new_value=new_status,
            metadata={"reason": reason} if reason else None,
            summary=f"instance {inst.get('name')} status {old_status} -> {new_status}",
            links=[audit.instance(uid, inst.get("name"))],
        )
    return inst


def _audit_reconcile(inst, action, summary, metadata=None):
    audit.record_system(
        "container.reconcile.action",
        actor_kind="service",
        origin="scheduler",
        target_type="instance",
        target_uid=inst.get("uid"),
        target_label=inst.get("name"),
        metadata={**(metadata or {}), "reconcile_action": action},
        summary=summary,
        links=[audit.instance(inst.get("uid"), inst.get("name"))],
    )


class ContainerService(BaseService):
    default_enabled = False
    min_interval = 1
    title = "Containers"
    description = (
        "Reconciles desired container state against the docker daemon: launches, stops, restarts, "
        "reaps orphans, fires schedules, and samples per-instance metrics. Requires access to the "
        "docker socket."
    )
    config_fields = [
        ConfigField(
            "container_metrics_every",
            "Metrics sample every (ticks)",
            type="int",
            default=1,
            minimum=1,
            maximum=60,
            help="Sample docker stats once every N reconcile ticks.",
            group="Containers",
        ),
    ]

    def __init__(self):
        super().__init__(name="containers", interval_seconds=5)
        self._metric_tick = 0
        self._booted = False
        self._last_sync_at = 0.0

    async def run_once(self) -> None:
        backend = get_backend()
        try:
            ps_rows = await backend.ps(label_filter=api.INSTANCE_LABEL)
        except Exception as exc:
            self.log(f"docker ps failed: {exc}")
            return

        by_uid = {}
        for row in ps_rows:
            uid = row.labels.get(api.INSTANCE_LABEL)
            if uid:
                by_uid[uid] = row

        instances = store.all_instances()
        known = {inst["uid"] for inst in instances}

        if not self._booted:
            self._boot_pass(instances)
            self._booted = True

        await self._periodic_sync(instances, by_uid)

        for uid, row in by_uid.items():
            if uid not in known:
                try:
                    await backend.rm(row.container_id, force=True)
                    self.log(f"reaped orphan container {row.name}")
                    _audit_reconcile(
                        {"uid": uid, "name": row.name},
                        "orphan_reap",
                        f"reconciler reaped orphan container {row.name}",
                    )
                except Exception as exc:
                    self.log(f"orphan rm failed for {row.name}: {exc}")

        for inst in instances:
            try:
                await self._reconcile(backend, inst, by_uid.get(inst["uid"]))
            except Exception as exc:
                self.log(f"reconcile {inst.get('name')} failed: {exc}")

        await self._fire_schedules()

        self._metric_tick += 1
        if (
            self._metric_tick
            % max(1, self.get_config().get("container_metrics_every", 1))
            == 0
        ):
            await self._sample_metrics(backend, by_uid)

    async def _reconcile(self, backend, inst, ps) -> None:
        uid = inst["uid"]
        desired = inst["desired_state"]
        status = inst["status"]

        if status == store.ST_REMOVING:
            if ps is not None:
                await backend.rm(ps.container_id, force=True)
            store.delete_instance(uid)
            self.log(f"removed instance {inst['name']}")
            return

        if desired == store.DESIRED_RUNNING:
            if ps is None:
                await self._launch(backend, inst)
            elif ps.state == "running":
                changes = {}
                if status != store.ST_RUNNING:
                    changes["status"] = store.ST_RUNNING
                    changes["container_id"] = ps.container_id
                    changes["started_at"] = inst.get("started_at") or store.now()
                ip, gateway = await self._capture_net(backend, ps.container_id)
                if ip and ip != (inst.get("container_ip") or ""):
                    changes["container_ip"] = ip
                if gateway and gateway != (inst.get("container_gateway") or ""):
                    changes["container_gateway"] = gateway
                if changes:
                    _set_status(inst, changes)
            elif ps.state == "paused":
                await backend.unpause(ps.container_id)
                _set_status(inst, {"status": store.ST_RUNNING}, reason="unpause")
            elif ps.state == "created":
                try:
                    await backend.start(ps.container_id)
                except Exception as exc:
                    self.log(f"start {inst['name']} failed, recreating: {exc}")
                    store.record_event(
                        inst, "recreated", "reconciler", "", {"reason": str(exc)}
                    )
                    await backend.rm(ps.container_id, force=True)
                    await self._launch(backend, inst)
                    return
                _set_status(inst, {"status": store.ST_RUNNING}, reason="start")
            elif status == store.ST_RUNNING:
                await self._handle_exit(backend, inst, ps)
            else:
                await backend.rm(ps.container_id, force=True)
                await self._launch(backend, inst)

        elif desired == store.DESIRED_STOPPED:
            if ps is not None and ps.state in ("running", "restarting", "paused"):
                await backend.stop(ps.container_id)
            if status != store.ST_STOPPED:
                _set_status(
                    inst,
                    {"status": store.ST_STOPPED, "stopped_at": store.now()},
                    reason="stopped",
                )

        elif desired == store.DESIRED_PAUSED:
            if ps is not None and ps.state == "running":
                await backend.pause(ps.container_id)
            if status != store.ST_PAUSED:
                _set_status(inst, {"status": store.ST_PAUSED}, reason="paused")

    async def _launch(self, backend, inst) -> None:
        await self._sync_one(inst)
        try:
            await asyncio.to_thread(api.materialize_boot_script, inst)
        except Exception as exc:
            self.log(f"boot script materialize {inst['name']} failed: {exc}")
        spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
        try:
            cid = await backend.run(spec)
        except Exception as exc:
            _set_status(inst, {"status": store.ST_CRASHED}, reason="launch_failed")
            store.record_event(
                inst, "launch_failed", "reconciler", "", {"reason": str(exc)}
            )
            self.log(f"launch {inst['name']} failed: {exc}")
            return
        changes = {
            "container_id": cid,
            "status": store.ST_RUNNING,
            "started_at": store.now(),
        }
        ip, gateway = await self._capture_net(backend, cid)
        if ip:
            changes["container_ip"] = ip
        if gateway:
            changes["container_gateway"] = gateway
        _set_status(inst, changes, reason="launch")
        store.record_event(inst, "start", "reconciler", "")
        self.log(f"launched instance {inst['name']}")
        _audit_reconcile(inst, "start", f"reconciler started instance {inst['name']}")

    def _boot_pass(self, instances) -> None:
        for inst in instances:
            if int(inst.get("start_on_boot") or 0) != 1:
                continue
            if inst.get("desired_state") == store.DESIRED_RUNNING:
                continue
            store.update_instance(inst["uid"], {"desired_state": store.DESIRED_RUNNING})
            inst["desired_state"] = store.DESIRED_RUNNING
            store.record_event(inst, "start_on_boot", "reconciler", "")
            self.log(f"start_on_boot forced running for {inst['name']}")

    def _sync_user(self, inst) -> dict:
        from devplacepy import database

        for uid in (
            inst.get("run_as_uid"),
            inst.get("created_by"),
            inst.get("owner_uid"),
        ):
            if not uid:
                continue
            user = database.get_users_by_uids([uid]).get(uid)
            if user:
                return user
        return {"uid": "system", "username": "system"}

    async def _sync_one(self, inst) -> None:
        try:
            await asyncio.to_thread(
                api.sync_bidirectional_sync, inst, self._sync_user(inst)
            )
        except Exception as exc:
            self.log(f"sync {inst.get('name')} failed: {exc}")

    async def _periodic_sync(self, instances, by_uid) -> None:
        now = time.monotonic()
        if now - self._last_sync_at < SYNC_EVERY_SECONDS:
            return
        self._last_sync_at = now
        for inst in instances:
            ps = by_uid.get(inst["uid"])
            if ps is None or ps.state != "running":
                continue
            await self._sync_one(inst)

    async def _capture_net(self, backend, cid: str) -> tuple:
        try:
            data = await backend.inspect(cid)
        except Exception:
            return "", ""
        return (
            api.container_ip_from_inspect(data),
            api.container_gateway_from_inspect(data),
        )

    async def _exit_logs(self, backend, container_id: str) -> str:
        lines: list = []

        async def collect(line: str) -> None:
            lines.append(line)

        try:
            await backend.logs(container_id, follow=False, tail=20, on_log=collect)
        except Exception:
            return ""
        return "\n".join(lines)[-2000:]

    async def _handle_exit(self, backend, inst, ps) -> None:
        uid = inst["uid"]
        exit_code = ps.exit_code or 0
        logs = await self._exit_logs(backend, ps.container_id)
        policy = inst.get("restart_policy", "never")
        if policy in AUTO_RESTART and not (policy == "on-failure" and exit_code == 0):
            try:
                await backend.start(ps.container_id)
                _set_status(
                    inst,
                    {
                        "status": store.ST_RUNNING,
                        "restart_count": int(inst.get("restart_count") or 0) + 1,
                    },
                    reason="policy_restart",
                )
                store.record_event(
                    inst,
                    "policy_restart",
                    "reconciler",
                    "",
                    {"exit_code": exit_code, "logs": logs},
                )
                _audit_reconcile(
                    inst,
                    "policy_restart",
                    f"reconciler restarted instance {inst['name']} per {policy} policy",
                    {"exit_code": exit_code},
                )
                return
            except Exception as exc:
                self.log(f"policy restart {inst['name']} failed: {exc}")
        terminal = store.ST_CRASHED if exit_code != 0 else store.ST_STOPPED
        _set_status(
            inst,
            {
                "status": terminal,
                "desired_state": store.DESIRED_STOPPED,
                "exit_code": exit_code,
                "stopped_at": store.now(),
            },
            reason="exit",
        )
        if terminal == store.ST_CRASHED:
            store.record_event(
                inst, "crash", "reconciler", "", {"exit_code": exit_code, "logs": logs}
            )

    async def _fire_schedules(self) -> None:
        now = now_utc()
        now_iso = to_iso(now)
        for sched in store.due_schedules(now_iso):
            inst = store.get_instance(sched["instance_uid"])
            if inst is None:
                store.delete_schedule(sched["uid"])
                continue
            action = sched["action"]
            desired = (
                store.DESIRED_RUNNING if action == "start" else store.DESIRED_STOPPED
            )
            store.update_instance(inst["uid"], {"desired_state": desired})
            store.record_event(inst, f"schedule_{action}", "scheduler", "")
            _audit_reconcile(
                inst,
                f"schedule_{action}",
                f"reconciler fired scheduled {action} on instance {inst['name']}",
                {"schedule_uid": sched["uid"]},
            )
            cols = json.loads(sched.get("schedule_json") or "{}")
            run_count = int(sched.get("run_count") or 0) + 1
            upcoming = next_run(
                cols.get("kind"), cols.get("every_seconds"), cols.get("cron"), now
            )
            changes = {"last_run_at": now_iso, "run_count": run_count}
            max_runs = cols.get("max_runs")
            if upcoming is None or (max_runs and run_count >= max_runs):
                changes["enabled"] = 0
                changes["next_run_at"] = ""
            else:
                changes["next_run_at"] = to_iso(upcoming)
            store.update_schedule(sched["uid"], changes)
            self.log(f"schedule fired: {action} {inst['name']}")

    async def _sample_metrics(self, backend, by_uid) -> None:
        running = {uid: row for uid, row in by_uid.items() if row.state == "running"}
        if not running:
            return
        try:
            samples = await backend.stats_once(
                [row.container_id for row in running.values()]
            )
        except Exception as exc:
            self.log(f"docker stats failed: {exc}")
            return
        name_to_uid = {row.name: uid for uid, row in running.items()}
        for name, sample in samples.items():
            uid = name_to_uid.get(name)
            if uid:
                store.insert_metric(uid, sample)

    def collect_metrics(self) -> dict:
        instances = store.all_instances()
        counts = {}
        for inst in instances:
            counts[inst["status"]] = counts.get(inst["status"], 0) + 1
        running = counts.get(store.ST_RUNNING, 0)
        stats = [
            {"label": "Instances", "value": len(instances)},
            {"label": "Running", "value": running},
            {"label": "Stopped", "value": counts.get(store.ST_STOPPED, 0)},
            {"label": "Crashed", "value": counts.get(store.ST_CRASHED, 0)},
            {"label": "Paused", "value": counts.get(store.ST_PAUSED, 0)},
        ]
        rows = [
            [
                inst.get("name", "")[:32],
                inst.get("status", ""),
                inst.get("desired_state", ""),
                inst.get("restart_policy", ""),
                int(inst.get("restart_count") or 0),
            ]
            for inst in instances[:15]
        ]
        table = {
            "columns": ["Instance", "Status", "Desired", "Policy", "Restarts"],
            "rows": rows,
        }
        return {"stats": stats, "table": table}
