# retoor <retoor@molodetz.nl>

import logging
import os

from devplacepy.config import PORT
from devplacepy.services.base import BaseService, ConfigField

from . import config
from .config import (
    DEFAULT_AI_MODEL,
    DEFAULT_AI_URL,
    build_settings,
)
from .cost.tracker import Pricing
from .tasks import limits
from .tasks.guards import DEFAULT_MAX_PER_OWNER, REASON_OWNER_IDLE
from .tasks.scheduler import GlobalScheduler, retire

logger = logging.getLogger("devii.service")

INSTANCE_ORIGIN_DEFAULT = f"http://127.0.0.1:{PORT}"


class DeviiService(BaseService):
    default_enabled = False
    min_interval = 10
    title = "Devii"
    description = (
        "An in-platform agentic assistant exposed as a WebSocket terminal at /devii/ws. "
        "Each signed-in user's Devii operates their own DevPlace account; guests get a "
        "sandbox session. Spend is capped per user and per guest over a rolling 24 hours, "
        "with every turn recorded to a usage ledger and an audit log."
    )
    config_fields = [
        ConfigField(
            config.FIELD_AI_URL,
            "AI URL",
            type="url",
            default=DEFAULT_AI_URL,
            help="OpenAI-compatible chat-completions endpoint Devii reasons with.",
            group="AI",
        ),
        ConfigField(
            config.FIELD_AI_MODEL,
            "AI model",
            type="str",
            default=DEFAULT_AI_MODEL,
            help="Model name sent to the AI endpoint.",
            group="AI",
        ),
        ConfigField(
            config.FIELD_AI_KEY,
            "AI API key",
            type="password",
            default="",
            secret=True,
            help="Defaults to the DEVII_AI_KEY env var, then the gateway's internal key.",
            group="AI",
        ),
        ConfigField(
            "devii_base_url",
            "Platform base URL",
            type="url",
            default="",
            help="Origin Devii drives via each user's API key. Blank uses this instance.",
            group="AI",
        ),
        ConfigField(
            config.FIELD_TIMEOUT,
            "AI request timeout (seconds)",
            type="float",
            default=config.DEFAULT_TIMEOUT_SECONDS,
            minimum=config.MIN_TIMEOUT_SECONDS,
            help="Read timeout for each AI chat-completions call. Must be at least as large "
            "as the gateway's upstream timeout or large prompts abort early. Minimum five minutes.",
            group="Reliability",
        ),
        ConfigField(
            config.FIELD_FETCH_TIMEOUT,
            "Web fetch timeout (seconds)",
            type="float",
            default=config.DEFAULT_FETCH_TIMEOUT_SECONDS,
            minimum=config.MIN_TIMEOUT_SECONDS,
            help="Read timeout for the fetch tool when Devii retrieves a URL. Minimum five minutes.",
            group="Reliability",
        ),
        ConfigField(
            config.FIELD_PLAN_REQUIRED,
            "Require plan step",
            type="bool",
            default=True,
            help="Force a plan() call before any tool use.",
            group="Agent",
        ),
        ConfigField(
            config.FIELD_VERIFY_REQUIRED,
            "Require verify step",
            type="bool",
            default=True,
            help="Force a verify() call after mutating actions.",
            group="Agent",
        ),
        ConfigField(
            config.FIELD_MAX_ITERATIONS,
            "Max tool iterations",
            type="int",
            default=40,
            minimum=1,
            maximum=200,
            help="Upper bound on tool-loop iterations per turn.",
            group="Agent",
        ),
        ConfigField(
            config.FIELD_ALLOW_EVAL,
            "Allow JavaScript execution",
            type="bool",
            default=True,
            help="Let Devii run JavaScript in the user's own browser (run_js tool). "
            "Other client tools (navigate, reload, highlight, toast, context) are unaffected.",
            group="Agent",
        ),
        ConfigField(
            config.FIELD_BROWSER_CONTROL,
            "Allow browser control",
            type="bool",
            default=True,
            help="Let Devii drive the user's own browser with structured tools "
            "(discover elements, click, fill, submit, wait, sequences). Independent of "
            "raw JavaScript execution; read-only tools (context, discover, read) are unaffected.",
            group="Agent",
        ),
        ConfigField(
            config.FIELD_INTERACTIONS_DEFAULT,
            "Interactive widgets default",
            type="bool",
            default=True,
            help="Site default for CA-IWP interactive prompts (ui_prompt). Guests always use "
            "this default. Signed-in users inherit it until they override it on their profile "
            "or with the interactions_set tool.",
            group="Agent",
        ),
        ConfigField(
            config.FIELD_USER_DAILY_USD,
            "Max USD per user / 24h",
            type="float",
            default=1.0,
            minimum=0,
            help="Rolling 24-hour spend cap for a signed-in user.",
            group="Limits",
        ),
        ConfigField(
            config.FIELD_GUEST_DAILY_USD,
            "Max USD per guest / 24h",
            type="float",
            default=0.05,
            minimum=0,
            help="Rolling 24-hour spend cap for an anonymous guest.",
            group="Limits",
        ),
        ConfigField(
            config.FIELD_ADMIN_DAILY_USD,
            "Max USD per admin / 24h",
            type="float",
            default=0.0,
            minimum=0,
            help="Rolling 24-hour spend cap for administrators. 0 = unlimited.",
            group="Limits",
        ),
        ConfigField(
            config.FIELD_GUESTS_ENABLED,
            "Allow guests",
            type="bool",
            default=True,
            help="When off, only signed-in users may use Devii.",
            group="Limits",
        ),
        ConfigField(
            config.FIELD_PRICE_CACHE_HIT,
            "Price cache-hit / 1M",
            type="float",
            default=0.0028,
            minimum=0,
            help="USD per million cache-hit input tokens.",
            group="Pricing",
        ),
        ConfigField(
            config.FIELD_PRICE_CACHE_MISS,
            "Price cache-miss / 1M",
            type="float",
            default=0.14,
            minimum=0,
            help="USD per million cache-miss input tokens.",
            group="Pricing",
        ),
        ConfigField(
            config.FIELD_PRICE_OUTPUT,
            "Price output / 1M",
            type="float",
            default=0.28,
            minimum=0,
            help="USD per million output tokens.",
            group="Pricing",
        ),
        ConfigField(
            config.FIELD_RSEARCH_ENABLED,
            "Enable web search tools",
            type="bool",
            default=True,
            help="Allow the external rsearch_* tools (web/image search, web-grounded AI answer, "
            "chat, image description). When off, those tools are refused. These reach an external "
            "public service, not this platform.",
            group="Web search",
        ),
        ConfigField(
            config.FIELD_RSEARCH_URL,
            "Web search service URL",
            type="url",
            default=config.DEFAULT_RSEARCH_URL,
            help="Base URL of the rsearch-compatible service the rsearch_* tools call.",
            group="Web search",
        ),
        ConfigField(
            config.FIELD_RSEARCH_TIMEOUT,
            "Web search timeout (seconds)",
            type="float",
            default=config.DEFAULT_RSEARCH_TIMEOUT_SECONDS,
            minimum=config.MIN_TIMEOUT_SECONDS,
            help="Read timeout for rsearch_* calls. Web-grounded answers can take several "
            "minutes, so this is generous by default. Minimum five minutes.",
            group="Web search",
        ),
        ConfigField(
            config.FIELD_EMAIL_ENABLED,
            "Enable email tools",
            type="bool",
            default=True,
            help="Allow the email_* tools (configure accounts, list/read/search/flag/move/delete "
            "messages, send mail) for signed-in users. These connect to the user's own external "
            "mailbox over IMAP/SMTP, not this platform. When off, those tools are refused.",
            group="Email",
        ),
        ConfigField(
            config.FIELD_EMAIL_TIMEOUT,
            "Email timeout (seconds)",
            type="float",
            default=config.DEFAULT_EMAIL_TIMEOUT_SECONDS,
            minimum=1,
            help="Connection/read timeout for IMAP and SMTP calls.",
            group="Email",
        ),
        ConfigField(
            config.FIELD_TASK_MAX_CONCURRENT,
            "Max concurrent scheduled tasks",
            type="int",
            default=config.DEFAULT_TASK_MAX_CONCURRENT,
            minimum=1,
            help="Upper bound on scheduled tasks running at the same time across ALL owners. "
            "Slots are handed out round-robin, one at a time per owner, so a single owner can "
            "never monopolise the scheduler.",
            group="Automation",
        ),
        ConfigField(
            config.FIELD_TASK_MAX_PER_OWNER,
            "Max active tasks per owner",
            type="int",
            default=DEFAULT_MAX_PER_OWNER,
            minimum=0,
            help="How many enabled tasks one administrator may hold at once. 0 disables the cap.",
            group="Automation",
        ),
        ConfigField(
            limits.FIELD_MEMBER_CREATE,
            "Member tasks created / 24h",
            type="int",
            default=limits.DEFAULT_MEMBER_CREATE,
            minimum=0,
            help="How many tasks a member may create in a rolling 24 hours. Deleting a task does "
            "not give the slot back. 0 = unlimited.",
            group="Automation",
        ),
        ConfigField(
            limits.FIELD_MEMBER_RUNS,
            "Member task runs / 24h",
            type="int",
            default=limits.DEFAULT_MEMBER_RUNS,
            minimum=0,
            help="How many scheduled runs a member's tasks may execute in a rolling 24 hours. "
            "A run over the limit is postponed until a slot frees, never dropped. 0 = unlimited.",
            group="Automation",
        ),
        ConfigField(
            limits.FIELD_ADMIN_CREATE,
            "Administrator tasks created / 24h",
            type="int",
            default=limits.DEFAULT_ADMIN_CREATE,
            minimum=0,
            help="Same rolling 24-hour creation quota for administrators. 0 = unlimited.",
            group="Automation",
        ),
        ConfigField(
            limits.FIELD_ADMIN_RUNS,
            "Administrator task runs / 24h",
            type="int",
            default=limits.DEFAULT_ADMIN_RUNS,
            minimum=0,
            help="Rolling 24-hour run quota for administrators. 0 = unlimited.",
            group="Automation",
        ),
        ConfigField(
            config.FIELD_TASK_DAILY_USD,
            "Max USD per owner / 24h for automation",
            type="float",
            default=config.DEFAULT_TASK_DAILY_USD,
            minimum=0,
            help="Rolling 24-hour spend cap for SCHEDULED runs, separate from the interactive "
            "quota (administrators are exempt from that one by default). 0 = unlimited.",
            group="Automation",
        ),
        ConfigField(
            config.FIELD_TASK_MAX_FAILURES,
            "Max consecutive task failures",
            type="int",
            default=config.DEFAULT_TASK_MAX_FAILURES,
            minimum=0,
            help="A task that fails this many times in a row disables itself. 0 disables the "
            "check, which lets a broken task retry forever.",
            group="Automation",
        ),
        ConfigField(
            config.FIELD_TASK_IDLE_DAYS,
            "Disable tasks after owner idle (days)",
            type="int",
            default=config.DEFAULT_TASK_IDLE_DAYS,
            minimum=0,
            help="Tasks of an owner who has not been seen for this many days are disabled on the "
            "housekeeping pass. 0 disables the check.",
            group="Automation",
        ),
        ConfigField(
            "devii_lessons_max_per_owner",
            "Max lessons per owner",
            type="int",
            default=500,
            minimum=0,
            help="Soft cap on active lessons per user/guest. When exceeded, the oldest lessons are pruned. 0 disables the cap.",
            group="Memory",
        ),
        ConfigField(
            "devii_lessons_max_age_days",
            "Max lesson age (days)",
            type="int",
            default=90,
            minimum=1,
            help="Lessons older than this are soft-deleted on the periodic housekeeping pass.",
            group="Memory",
        ),
    ]

    def __init__(self):
        super().__init__(name="devii", interval_seconds=60)
        self._hub = None
        self._scheduler = None

    def hub(self):
        if self._hub is None:
            from .hub import DeviiHub

            self._hub = DeviiHub(self._build_settings)
        return self._hub

    def effective_config(self) -> dict:
        from devplacepy.database import internal_gateway_key

        cfg = self.get_config()
        cfg[config.FIELD_AI_KEY] = (
            cfg[config.FIELD_AI_KEY]
            or os.environ.get("DEVII_AI_KEY", "")
            or internal_gateway_key()
        )
        return cfg

    def instance_base_url(self) -> str:
        cfg = self.get_config()
        configured = cfg.get("devii_base_url", "")
        if configured:
            return configured
        return os.environ.get("DEVII_BASE_URL", INSTANCE_ORIGIN_DEFAULT)

    def _pricing(self, cfg: dict) -> Pricing:
        return Pricing(
            model=cfg[config.FIELD_AI_MODEL] or DEFAULT_AI_MODEL,
            cache_hit_per_m=float(cfg[config.FIELD_PRICE_CACHE_HIT]),
            cache_miss_per_m=float(cfg[config.FIELD_PRICE_CACHE_MISS]),
            output_per_m=float(cfg[config.FIELD_PRICE_OUTPUT]),
        )

    def _build_settings(
        self,
        api_key: str,
        base_url: str,
        owner_kind: str = "guest",
        is_admin: bool = False,
    ):
        cfg = self.effective_config()
        settings = build_settings(cfg, base_url, api_key, owner_kind, is_admin)
        return settings, self._pricing(cfg)

    def daily_limit_for(self, owner_kind: str, is_admin: bool = False) -> float:
        return config.effective_daily_limit(self.get_config(), owner_kind, is_admin)

    def guests_enabled(self) -> bool:
        return bool(self.get_config()[config.FIELD_GUESTS_ENABLED])

    def spent_24h(self, owner_kind: str, owner_id: str) -> float:
        return self.hub().ledger.spent_24h(owner_kind, owner_id)

    def quota_exceeded(
        self, owner_kind: str, owner_id: str, is_admin: bool = False
    ) -> bool:
        limit = self.daily_limit_for(owner_kind, is_admin)
        return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit

    def reset_quota(self, owner_kind: str, owner_id: str) -> int:
        return self.hub().ledger.reset(owner_kind, owner_id)

    def reset_guest_quotas(self) -> int:
        return self.hub().ledger.reset_owner_kind("guest")

    def reset_all_quotas(self) -> int:
        return self.hub().ledger.reset_all()

    async def run_once(self) -> None:
        if not self.is_enabled():
            return
        hub = self.hub()
        self._configure_scheduler()
        idle = self._disable_idle_owner_tasks()
        runs_pruned = self._prune_task_runs()
        pruned = hub.ledger.prune(48)
        removed = await hub.gc_idle()
        lessons_pruned = self._prune_old_lessons()
        if pruned or removed or idle or lessons_pruned or runs_pruned:
            self.log(
                f"Housekeeping: disabled {idle} idle-owner task(s), pruned {runs_pruned} task-run "
                f"rows, pruned {pruned} ledger rows, pruned {lessons_pruned} lessons, closed "
                f"{removed} idle sessions"
            )

    def _prune_task_runs(self) -> int:
        from devplacepy.database import db
        from .tasks.schedule import now_utc

        try:
            return limits.prune_runs(db, now_utc())
        except Exception:  # noqa: BLE001 - a bad read must not stop housekeeping
            logger.exception("Failed to prune Devii task-run rows")
            return 0

    def _prune_old_lessons(self) -> int:
        try:
            from devplacepy.database import db
            from .agentic.lessons import LessonStore, _read_retention_settings

            _, max_age = _read_retention_settings(db)
            store = LessonStore(db, "_global", "_global")
            pruned = store.prune_all_owners(max_age)
            if pruned:
                logger.info("Devii lessons housekeeping: pruned %d across all owners", pruned)
            return pruned
        except Exception:
            logger.exception("Devii lessons housekeeping failed")
            return 0

    def scheduler(self) -> GlobalScheduler:
        if self._scheduler is None:
            from devplacepy.database import db

            cfg = self.get_config()
            self._scheduler = GlobalScheduler(
                db,
                self._resolve_task_owner,
                tick_seconds=1.0,
                max_concurrent=int(cfg[config.FIELD_TASK_MAX_CONCURRENT]),
                max_failures=int(cfg[config.FIELD_TASK_MAX_FAILURES]),
                budget_exceeded=self.automation_budget_exceeded,
            )
        return self._scheduler

    def _configure_scheduler(self) -> None:
        cfg = self.get_config()
        self.scheduler().configure(
            int(cfg[config.FIELD_TASK_MAX_CONCURRENT]),
            int(cfg[config.FIELD_TASK_MAX_FAILURES]),
        )

    def automation_budget_exceeded(self, owner_kind: str, owner_id: str) -> bool:
        limit = float(self.get_config()[config.FIELD_TASK_DAILY_USD] or 0.0)
        return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit

    def _resolve_task_owner(self, row: dict):
        from devplacepy.database import db
        from devplacepy.utils import is_admin, is_primary_admin

        owner_id = str(row.get("owner_id") or "")
        if str(row.get("owner_kind") or "") != "user" or not owner_id:
            return None
        users = db["users"] if "users" in db.tables else None
        user = users.find_one(uid=owner_id, deleted_at=None) if users else None
        if not user:
            return None
        session = self.hub().get_or_create(
            "user",
            owner_id,
            user.get("username", ""),
            user.get("api_key", ""),
            self.instance_base_url(),
            is_admin=is_admin(user),
            is_primary_admin=is_primary_admin(user),
            channel="main",
        )
        session.set_timezone(user.get("timezone") or "")
        return session.scheduler_binding()

    def _disable_idle_owner_tasks(self) -> int:
        from datetime import datetime, timedelta, timezone

        from devplacepy.database import db
        from .tasks.store import TABLE, TaskStore

        days = int(self.get_config()[config.FIELD_TASK_IDLE_DAYS] or 0)
        if days <= 0 or TABLE not in db.tables:
            return 0
        cutoff = datetime.now(timezone.utc) - timedelta(days=days)
        users = db["users"] if "users" in db.tables else None
        if users is None:
            return 0
        disabled = 0
        rows = db[TABLE].find(enabled=True, owner_kind="user", deleted_at=None)
        for row in rows:
            user = users.find_one(uid=row.get("owner_id"))
            last_seen = (user or {}).get("last_seen")
            if not last_seen:
                continue
            try:
                seen_at = datetime.fromisoformat(str(last_seen))
            except ValueError:
                continue
            if seen_at.tzinfo is None:
                seen_at = seen_at.replace(tzinfo=timezone.utc)
            if seen_at >= cutoff:
                continue
            store = TaskStore(db, "user", str(row.get("owner_id")))
            retire(store, row, REASON_OWNER_IDLE)
            disabled += 1
        return disabled

    async def on_enable(self) -> None:
        self.scheduler().start()

    async def on_disable(self) -> None:
        if self._scheduler is not None:
            await self._scheduler.stop()
            self._scheduler = None
        if self._hub is not None:
            await self._hub.aclose()
            self._hub = None

    def collect_metrics(self) -> dict:
        cfg = self.get_config()
        hub = self._hub
        active = hub.active_sessions() if hub is not None else 0
        conns = hub.connection_count() if hub is not None else 0
        spent = hub.ledger.total_spent_24h() if hub is not None else 0.0
        return {
            "stats": [
                {"label": "Active sessions", "value": active},
                {"label": "Connections", "value": conns},
                {"label": "Spend 24h", "value": f"${spent:.4f}"},
                {
                    "label": "User cap 24h",
                    "value": f"${float(cfg[config.FIELD_USER_DAILY_USD]):.2f}",
                },
                {
                    "label": "Guest cap 24h",
                    "value": f"${float(cfg[config.FIELD_GUEST_DAILY_USD]):.2f}",
                },
                {
                    "label": "Admin cap 24h",
                    "value": (
                        f"${float(cfg[config.FIELD_ADMIN_DAILY_USD]):.2f}"
                        if float(cfg[config.FIELD_ADMIN_DAILY_USD]) > 0
                        else "unlimited"
                    ),
                },
                {
                    "label": "Guests",
                    "value": "on" if cfg[config.FIELD_GUESTS_ENABLED] else "off",
                },
                {
                    "label": "Tasks running",
                    "value": (
                        f"{self._scheduler.running}/"
                        f"{int(cfg[config.FIELD_TASK_MAX_CONCURRENT])}"
                        if self._scheduler is not None
                        else "0"
                    ),
                },
                {"label": "Model", "value": cfg[config.FIELD_AI_MODEL]},
            ]
        }
