"""A chat that does not exist yet, so its panels can.

Chats are created lazily -- there is no endpoint that makes an empty one, and
the row appears together with its first message. That is a rule worth keeping:
an opened-and-abandoned composer should leave nothing behind. But it also meant
the terminal and the canvas were unavailable on the one screen where you are
deciding *which machine to work on*, which is exactly when you want to look
around it first.

A draft is the smallest thing that fixes that: an id, and the three facts the
panels need behind it. It is not a chat and never becomes one -- when the first
prompt is sent, a real chat is created and the draft's shell and tabs are
**adopted** into it, which is a re-key and a copy rather than a promotion.

The id is derived from (owner, connection, directory) rather than invented, so
that returning to the same new-chat screen finds the same shell and the same
tabs instead of quietly starting a second one. It is a hash so that neither the
directory nor the owner is legible in a URL.
"""

from __future__ import annotations

import hashlib
import time
from dataclasses import dataclass, field
from typing import Any

# How long a draft survives without being touched. Generous, because it is
# holding somebody's open files while they decide what to do; bounded, because
# nothing else will ever clean it up -- an abandoned new-chat screen leaves no
# row to cascade from and no chat to delete.
IDLE_TIMEOUT = 3600.0

# The prefix a draft id carries. It has to be distinguishable from a chat id at
# a glance and by code: `Chat.id` is 32 hex characters from `new_id`, so
# nothing here can collide with one by accident.
PREFIX = "draft_"


@dataclass
class Draft:
    """What a draft knows, which is only what the panels ask for."""

    id: str
    owner_id: str
    profile_id: str
    project_dir: str
    # The canvas's tab strip, in the shape `Chat.canvas_json` holds. In memory
    # rather than on a row for the obvious reason, and carried onto the chat at
    # adoption.
    canvas_json: dict = field(default_factory=dict)
    touched_at: float = field(default_factory=time.monotonic)


_DRAFTS: dict[str, Draft] = {}


def is_draft(chat_id: str) -> bool:
    return bool(chat_id) and chat_id.startswith(PREFIX)


def key_for(owner_id: str, profile_id: str, project_dir: str) -> str:
    """The id for one (owner, connection, directory), stably.

    Derived rather than random so that reopening the new-chat screen on the same
    target finds the shell that is already running there. The owner is in the
    hash so that two people pointed at the same directory of the same connection
    do not share a draft -- they would share a *shell*, and the terminal's own
    "one chat, one shell" rule is scoped to a person's chats.
    """
    material = "\0".join((owner_id, profile_id, project_dir or ""))
    digest = hashlib.sha256(material.encode("utf-8")).hexdigest()
    return f"{PREFIX}{digest[:24]}"


def remember(owner_id: str, profile_id: str, project_dir: str) -> Draft:
    """The draft for this target, created if this is the first time."""
    _sweep()
    key = key_for(owner_id, profile_id, project_dir)
    draft = _DRAFTS.get(key)
    if draft is None:
        draft = Draft(
            id=key, owner_id=owner_id, profile_id=profile_id, project_dir=project_dir or ""
        )
        _DRAFTS[key] = draft
    draft.touched_at = time.monotonic()
    return draft


def get(draft_id: str, owner_id: str) -> Draft | None:
    """One draft, if it is this person's.

    The id is a hash of the owner, so a draft belonging to somebody else cannot
    be guessed -- but it is checked rather than assumed, because "unguessable"
    is not an authorisation and the next caller might build the id differently.
    """
    draft = _DRAFTS.get(draft_id or "")
    if draft is None or draft.owner_id != owner_id:
        return None
    draft.touched_at = time.monotonic()
    return draft


def forget(draft_id: str) -> None:
    _DRAFTS.pop(draft_id or "", None)


def clear() -> None:
    _DRAFTS.clear()


def as_chat(draft: Draft) -> Any:
    """A `Chat` the panels can use, constructed and never saved.

    This is the whole trick, and it is worth being precise about why it is safe.
    `canvas.agent_ready`, `canvas._executor`, `_load_agent`/`_save_agent` and
    `agent_session.resolve` read exactly four things off a chat -- `user_id`,
    `kind`, `ssh_profile_id` and `project_dir` -- and none of them passes the
    chat to a query or writes it back. So a transient row satisfies every one of
    them unchanged, and no code that already works has to learn what a draft is.

    `id` and `canvas_json` are set explicitly: both are *column* defaults, which
    SQLAlchemy applies at flush, and this row is never flushed. An unset `id` is
    not a cosmetic problem -- see `SOURCES_NEEDING_A_CHAT`.
    """
    from lembas.db.models import KIND_AGENT, Chat

    return Chat(
        id=draft.id,
        user_id=draft.owner_id,
        kind=KIND_AGENT,
        ssh_profile_id=draft.profile_id,
        project_dir=draft.project_dir,
        canvas_json=dict(draft.canvas_json or {}),
        agent_mode="",
        scope_json={},
    )


# Canvas sources a draft may not open, refused by name.
#
# `scratch` needs a row: `scratch_service.for_chat` would write a `ScratchDoc`
# keyed on a chat that does not exist, which is the lazy-creation rule broken
# outright rather than bent.
#
# `file` is the one that matters. `canvas._load_file` authorises with
# `attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
# stored with `chat_id=None`. If a draft's chat carried no id, `None != None` is
# False and every unclaimed attachment its owner has would open from any draft
# canvas. `as_chat` sets an id, so that comparison already fails -- but relying
# on it would mean the guarantee lives in an id-shaped coincidence. It is stated
# here instead, where it can be read and tested.
SOURCES_NEEDING_A_CHAT = frozenset({"scratch", "file"})


def refuses(source: str) -> bool:
    return source in SOURCES_NEEDING_A_CHAT


def _sweep() -> None:
    """Drop drafts nobody has touched in a long while.

    On write rather than on a timer: a draft holds no connection and no process,
    only a little state, so there is nothing to close and nothing that leaks by
    being late. The shell it points at has its own reaper.
    """
    cutoff = time.monotonic() - IDLE_TIMEOUT
    for key in [k for k, d in _DRAFTS.items() if d.touched_at < cutoff]:
        _DRAFTS.pop(key, None)


__all__ = [
    "SOURCES_NEEDING_A_CHAT",
    "Draft",
    "as_chat",
    "clear",
    "forget",
    "get",
    "is_draft",
    "key_for",
    "refuses",
    "remember",
]
