# retoor <retoor@molodetz.nl>

import asyncio
import json
import logging
import shutil
import sqlite3
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

from devplacepy import config
from devplacepy.attachments import _directory_for
from devplacepy.services.backup import offload, store
from devplacepy.services.base import ConfigField
from devplacepy.services.devii.tasks.schedule import next_run as schedule_next_run
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.base import JobService, _human_bytes
from devplacepy.utils import generate_uid

logger = logging.getLogger(__name__)

WORKER_MODULE = "devplacepy.services.jobs.backup_worker"

DEFAULT_OFFLOAD_INTERVAL_SECONDS = 300
DEFAULT_OFFLOAD_KEEP_LOCAL = 1
DEFAULT_OFFLOAD_KEEP_REMOTE = 30


class BackupService(JobService):
    kind = "backup"
    title = "Backup"
    description = (
        "Builds compressed tar.gz backups of selected data targets in a subprocess off the "
        "request path, snapshots the database consistently, records full statistics, fires "
        "scheduled backups, and rotates them by retention."
    )

    def __init__(self):
        super().__init__(name="backup", interval_seconds=15)
        self._last_offload_at = 0.0
        self.offload_enabled_field = ConfigField(
            "backup_offload_enabled",
            "Offload to remote storage",
            type="bool",
            default=True,
            help=(
                "Ship completed backups to the configured remote storage and purge "
                "older local copies once a remote copy is confirmed."
            ),
            group="Offload",
        )
        self.offload_interval_field = ConfigField(
            "backup_offload_interval_seconds",
            "Offload check interval (seconds)",
            type="int",
            default=DEFAULT_OFFLOAD_INTERVAL_SECONDS,
            minimum=60,
            help="How often to check for backups to offload and rotate.",
            group="Offload",
        )
        self.offload_keep_local_field = ConfigField(
            "backup_offload_keep_local",
            "Local copies to keep per target",
            type="int",
            default=DEFAULT_OFFLOAD_KEEP_LOCAL,
            minimum=1,
            maximum=10,
            help="Local archives beyond this count are purged per target, once offloaded.",
            group="Offload",
        )
        self.offload_keep_remote_field = ConfigField(
            "backup_offload_keep_remote",
            "Remote copies to keep per target",
            type="int",
            default=DEFAULT_OFFLOAD_KEEP_REMOTE,
            minimum=1,
            maximum=1000,
            help="Remote archives beyond this count are deleted per target.",
            group="Offload",
        )
        self.config_fields += [
            self.offload_enabled_field,
            self.offload_interval_field,
            self.offload_keep_local_field,
            self.offload_keep_remote_field,
        ]

    async def run_once(self) -> None:
        await super().run_once()
        try:
            self._fire_due_schedules()
        except Exception as exc:
            self.log(f"Schedule pass failed: {exc}")
        await self._run_offload_cycle()

    async def _run_offload_cycle(self) -> None:
        if not self.offload_enabled_field.read():
            return
        interval = max(60, int(self.offload_interval_field.read()))
        now = time.monotonic()
        if now - self._last_offload_at < interval:
            return
        self._last_offload_at = now
        try:
            result = await offload.run_cycle(
                keep_local=int(self.offload_keep_local_field.read()),
                keep_remote=int(self.offload_keep_remote_field.read()),
                log=self.log,
            )
            if result["uploaded"] or result["purged_local"] or result["removed_remote"]:
                self.log(
                    f"Offload cycle: {result['uploaded']} uploaded, "
                    f"{result['purged_local']} local purged, "
                    f"{result['removed_remote']} remote pruned"
                )
        except Exception as exc:
            self.log(f"Offload cycle failed: {exc}")

    async def process(self, job: dict) -> dict:
        from devplacepy.services.audit import record as audit

        payload = job["payload"]
        target = payload.get("target", "")
        schedule_uid = payload.get("schedule_uid", "")
        keep_last = int(payload.get("keep_last") or 0)
        job_uid = job["uid"]
        record = store.get_backup_by_job(job_uid)
        if record is None:
            backup_uid = store.create_backup(
                target=target,
                created_by=job.get("owner_id", ""),
                job_uid=job_uid,
                schedule_uid=schedule_uid,
            )
            record = store.get_backup(backup_uid)
        backup_uid = record["uid"]

        if not store.is_valid_target(target):
            store.fail_backup(backup_uid, f"unknown backup target: {target}")
            raise ValueError(f"unknown backup target: {target}")

        store.mark_running(backup_uid)
        staging = config.BACKUP_STAGING_DIR / job_uid
        try:
            sources = await asyncio.to_thread(self._materialize, target, staging)
            spec_path = staging / "spec.json"
            await asyncio.to_thread(
                spec_path.write_text, json.dumps({"sources": sources})
            )
            final_dir = config.BACKUPS_DIR / _directory_for(backup_uid)
            await asyncio.to_thread(final_dir.mkdir, parents=True, exist_ok=True)
            filename = self._archive_name(target, backup_uid)
            tmp_path = final_dir / f"{generate_uid()}.partial"
            stats = await self._run_worker(spec_path, tmp_path)
            final_path = final_dir / filename
            await asyncio.to_thread(tmp_path.replace, final_path)
        except Exception as exc:
            store.fail_backup(backup_uid, str(exc) or exc.__class__.__name__)
            audit.record_system(
                "job.backup.failed",
                actor_kind="system",
                origin="scheduler" if schedule_uid else "web",
                result="failure",
                target_type="backup",
                target_uid=backup_uid,
                metadata={"target": target, "schedule_uid": schedule_uid},
                summary=f"backup {target} failed",
                links=[audit.job(job_uid)],
            )
            raise
        finally:
            await asyncio.to_thread(shutil.rmtree, staging, ignore_errors=True)

        store.finalize_backup(
            backup_uid,
            filename=filename,
            local_path=str(final_path),
            stats=stats,
        )
        removed = store.rotate_schedule(schedule_uid, keep_last)
        audit.record_system(
            "job.backup.complete",
            actor_kind="system",
            origin="scheduler" if schedule_uid else "web",
            target_type="backup",
            target_uid=backup_uid,
            metadata={
                "target": target,
                "schedule_uid": schedule_uid,
                "bytes_out": stats["bytes_out"],
                "sha256": stats["sha256"],
                "file_count": stats["file_count"],
                "rotated": removed,
            },
            summary=f"backup {target} completed ({_human_bytes(stats['bytes_out'])})",
            links=[audit.job(job_uid)],
        )
        return {
            "backup_uid": backup_uid,
            "target": target,
            "filename": filename,
            "local_path": str(final_path),
            "sha256": stats["sha256"],
            "file_count": stats["file_count"],
            "dir_count": stats["dir_count"],
            "bytes_in": stats["bytes_in"],
            "bytes_out": stats["bytes_out"],
            "item_count": stats["file_count"],
        }

    def cleanup(self, job: dict) -> None:
        shutil.rmtree(config.BACKUP_STAGING_DIR / job["uid"], ignore_errors=True)

    def _materialize(self, target: str, staging: Path) -> list[dict]:
        staging.mkdir(parents=True, exist_ok=True)
        sources: list[dict] = []
        if target in ("database", "full"):
            snapshot = staging / "db_snapshot"
            self._snapshot_databases(snapshot)
            sources.append({"root": "database", "path": str(snapshot)})
        if target in ("uploads", "full"):
            sources.append({"root": "uploads", "path": str(config.UPLOADS_DIR)})
        if target in ("keys", "full"):
            sources.append({"root": "keys", "path": str(config.KEYS_DIR)})
        return sources

    def _snapshot_databases(self, dest: Path) -> None:
        dest.mkdir(parents=True, exist_ok=True)
        database_file = Path(str(config.DATABASE_URL).replace("sqlite:///", ""))
        for source in (database_file, config.DEVII_TASKS_DB, config.DEVII_LESSONS_DB):
            if Path(source).exists():
                self._sqlite_backup(Path(source), dest / Path(source).name)

    def _sqlite_backup(self, source: Path, destination: Path) -> None:
        origin = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
        try:
            target = sqlite3.connect(str(destination))
            try:
                origin.backup(target)
            finally:
                target.close()
        finally:
            origin.close()

    async def _run_worker(self, spec_path: Path, output_path: Path) -> dict:
        proc = await asyncio.create_subprocess_exec(
            sys.executable,
            "-m",
            WORKER_MODULE,
            str(spec_path),
            str(output_path),
            cwd=str(config.BASE_DIR),
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        out, err = await proc.communicate()
        if proc.returncode != 0:
            raise RuntimeError(
                f"backup worker exited {proc.returncode}: "
                f"{err.decode('utf-8', 'replace')[:500]}"
            )
        payload = out.decode("utf-8").strip()
        if not payload:
            raise RuntimeError(
                f"backup worker produced no output: "
                f"{err.decode('utf-8', 'replace')[:500]}"
            )
        return json.loads(payload)

    def _archive_name(self, target: str, backup_uid: str) -> str:
        stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
        tail = backup_uid.replace("-", "")[-8:]
        return f"{target}-{stamp}-{tail}.tar.gz"

    def _fire_due_schedules(self) -> None:
        reference = to_iso(now_utc())
        for schedule in store.list_due_schedules(reference):
            self._enqueue_scheduled(schedule, reference)

    def _enqueue_scheduled(self, schedule: dict, reference: str) -> None:
        target = schedule["target"]
        if not store.is_valid_target(target):
            self.log(f"Schedule {schedule['uid']} skipped: unknown target {target}")
            return
        keep_last = int(schedule.get("keep_last") or 0)
        created_by = schedule.get("created_by", "")
        job_uid = queue.enqueue(
            "backup",
            {
                "target": target,
                "schedule_uid": schedule["uid"],
                "created_by": created_by,
                "keep_last": keep_last,
            },
            owner_kind="system",
            owner_id=created_by,
            preferred_name=f"{schedule.get('name', target)} ({target})",
        )
        store.create_backup(
            target=target,
            created_by=created_by,
            job_uid=job_uid,
            schedule_uid=schedule["uid"],
        )
        moment = schedule_next_run(
            schedule["kind"],
            int(schedule.get("every_seconds") or 0),
            schedule.get("cron") or None,
            now_utc(),
        )
        store.set_schedule_runtime(
            schedule["uid"],
            next_run_at=to_iso(moment) if moment else "",
            last_run_at=reference,
            last_job_uid=job_uid,
            run_count=int(schedule.get("run_count") or 0) + 1,
        )
        self.log(f"Scheduled backup '{schedule.get('name')}' enqueued ({job_uid})")
