"""SSH connections, kept by the people who own them.

Not an admin screen. These are somebody's own machines and somebody's own keys,
so the pages sit beside the library rather than under `/admin` -- an
administrator decides only whether the feature exists at all.

Trust on first use, made explicit. Adding a host does not connect to it; the
**Check** button looks at its key, shows the fingerprint, and waits. Only when
that is accepted is the key pinned, and only then will anything authenticate.
`asyncssh.get_server_host_key` completes the key exchange and stops, so a host
that has not been accepted is never offered a username, let alone a credential.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select

from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.pages import sidebar_context
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
from lembas.services import settings_store
from lembas.services.agent import index as index_service
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent import terminal as terminal_service
from lembas.services.agent.base import ExecError
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
from lembas.web.templating import render

log = logging.getLogger(__name__)

router = APIRouter(
    dependencies=[Depends(require_permission("agent.ssh"))], tags=["agents"]
)


def _profile(db: Db, user: RequiredUser, profile_id: str) -> SshProfile:
    """One profile belonging to this person.

    Ownership is the whole authorisation. `sharing.py` is deliberately not
    involved: it grants reading, and a host somebody else can read is a host
    they can log in to.
    """
    profile = db.get(SshProfile, profile_id)
    if profile is None or profile.owner_id != user.id:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
    return profile


def _owned(db: Db, user_id: str) -> list[SshProfile]:
    return list(
        db.scalars(
            select(SshProfile).where(SshProfile.owner_id == user_id).order_by(SshProfile.name)
        )
    )


def _back(message: str = "") -> Response:
    target = f"/agents?saved={message}" if message else "/agents"
    return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)


def _number(raw, *, default: int, low: int, high: int) -> int:
    text = str(raw or "").strip()
    if not text.isdigit():
        return default
    return min(max(int(text), low), high)


def _apply(profile: SshProfile, form) -> None:
    """Copy a submitted form onto a profile.

    Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
    `x`, and an absent one is exactly what an unticked box sends.
    """
    profile.name = str(form.get("name") or "").strip()[:120]
    profile.host = str(form.get("host") or "").strip()[:255]
    profile.username = str(form.get("username") or "").strip()[:120]
    profile.port = _number(form.get("port"), default=22, low=1, high=65535)
    profile.connect_timeout = _number(form.get("connect_timeout"), default=15, low=3, high=120)
    profile.default_dir = str(form.get("default_dir") or "").strip()[:500]

    method = str(form.get("auth") or "").strip()
    profile.auth = method if method in AUTH_METHODS else profile.auth
    profile.enabled = "enabled" in form


def _detail(
    request: Request,
    db: Db,
    user: RequiredUser,
    profile: SshProfile,
    *,
    is_new: bool,
    error: str = "",
    saved: str = "",
):
    # The user is passed rather than read off the profile: a draft has never
    # been attached to a session, so `profile.owner` is None on the one page
    # that most needs a sidebar.
    return render(
        request,
        "agents/detail.html",
        {
            **sidebar_context(db, user),
            "profile": profile,
            "is_new": is_new,
            "error": error,
            "saved": saved,
            "unchanged": UNCHANGED_SENTINEL,
            "masked_password": mask(decrypt(profile.password_encrypted))
            if profile.password_encrypted
            else "",
            "has_key": bool(profile.private_key_encrypted),
            "problem": ssh_service.available(),
        },
    )


@router.get("/agents")
async def agents_page(request: Request, db: Db, user: RequiredUser, saved: str = ""):
    return render(
        request,
        "agents/index.html",
        {
            **sidebar_context(db, user),
            "profiles": _owned(db, user.id),
            "saved": saved,
            "problem": ssh_service.available(),
            "enabled": bool(settings_store.agents(db).get("enabled")),
        },
    )


# Registered before /{profile_id}: FastAPI matches in registration order, so
# with the parameterised route first "new" is captured as an id. This has been
# a bug once already, in /admin/models.
@router.get("/agents/new")
async def new_profile_page(request: Request, db: Db, user: RequiredUser):
    draft = SshProfile(
        owner_id=user.id, name="", host="", username="", port=22, connect_timeout=15, enabled=True
    )
    return _detail(request, db, user, draft, is_new=True)


@router.post("/api/agents")
async def create_profile(request: Request, db: Db, user: RequiredUser) -> Response:
    form = await request.form()
    profile = SshProfile(owner_id=user.id)
    _apply(profile, form)

    if problem := _problem(db, profile, user.id):
        return _detail(request, db, user, profile, is_new=True, error=problem)

    profile.password_encrypted = keep_or_replace(str(form.get("password") or ""), "")
    profile.private_key_encrypted = keep_or_replace(str(form.get("private_key") or ""), "")
    profile.key_passphrase_encrypted = keep_or_replace(str(form.get("key_passphrase") or ""), "")
    db.add(profile)
    db.commit()

    log.info("%s added ssh profile %s", user.email, profile.name)
    return RedirectResponse(
        f"/agents/{profile.id}?saved=Added+{profile.name}.+Check+it+to+confirm+its+fingerprint.",
        status_code=status.HTTP_303_SEE_OTHER,
    )


def _problem(db: Db, profile: SshProfile, owner_id: str, *, existing_id: str = "") -> str:
    if not profile.name:
        return "A connection needs a name."
    if not profile.host:
        return "A connection needs a host."
    if not profile.username:
        return "A connection needs a username to log in as."

    clash = db.scalar(
        select(SshProfile).where(
            SshProfile.owner_id == owner_id, SshProfile.name == profile.name
        )
    )
    if clash is not None and clash.id != existing_id:
        return f"You already have a connection called “{profile.name}”."
    return ""


@router.get("/agents/{profile_id}")
async def profile_page(
    request: Request, db: Db, user: RequiredUser, profile_id: str, saved: str = ""
):
    profile = _profile(db, user, profile_id)
    return _detail(request, db, user, profile, is_new=False, saved=saved)


@router.get("/api/agents/{profile_id}/browse")
async def browse_profile(
    request: Request, db: Db, user: RequiredUser, profile_id: str, path: str = ""
):
    """One directory on the far side, as a fragment the picker swaps in.

    Hung off the profile rather than the chat because the commonest caller is
    the *new*-chat composer, where there is no chat yet -- the directory is one
    of the things being chosen. Ownership of the profile is the whole
    authorisation, as everywhere else in this module.

    This is a person clicking, not a model calling, so it does not go through
    `agent/policy.py`. That is the same argument the terminal panel rests on and
    it holds for the same reason -- somebody who owns the credential could list
    the directory with an ssh client -- but it does mean Manual mode's promise
    that everything is shown to you first now has a second exception. Both are
    written down in CLAUDE.md.
    """
    profile = _profile(db, user, profile_id)
    entries: list = []
    error = ""

    if hint := ssh_service.available():
        error = hint
    elif not profile.host_key:
        # connect_kwargs would raise the same thing, but a picker that opens on
        # a wall of prose about known_hosts is worse than one that says this.
        error = "This connection's host key has not been confirmed yet. Check it first."
    else:
        try:
            executor = ssh_service.SshExecutor(ssh_service.spec_from(profile), "")
            entries = await executor.scan_dir(path or profile.default_dir or "/")
        except ExecError as exc:
            error = exc.message

    here = path or profile.default_dir or "/"
    return render(
        request,
        "agents/_browse.html",
        {
            "profile": profile,
            "here": here,
            "parent": _parent_of(here),
            "entries": entries,
            "error": error,
        },
    )


def _parent_of(path: str) -> str:
    """The directory above, or "" at the root.

    Plain string work rather than pathlib: these are POSIX paths on somebody
    else's machine, and running them through a local Path would apply this
    host's rules to them.
    """
    trimmed = (path or "/").rstrip("/")
    if not trimmed or trimmed == "":
        return ""
    head = trimmed.rsplit("/", 1)[0]
    return head or "/"


@router.post("/api/agents/{profile_id}/check")
async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
    """Look at the host's key, and connect if it has already been accepted.

    Two steps in one button, because they are one question: *is this the machine
    I meant, and will it let me in?* An unseen key comes back as a fingerprint
    to accept; an accepted one is used to log in and run something harmless.
    """
    profile = _profile(db, user, profile_id)

    try:
        line, fingerprint = await ssh_service.capture_host_key(
            profile.host, profile.port, timeout=profile.connect_timeout
        )
    except ExecError as exc:
        profile.last_error = exc.message
        profile.last_checked_at = datetime.now(UTC)
        db.commit()
        return render(
            request, "agents/_check.html", {"profile": profile, "error": exc.message}
        )

    if not profile.host_key:
        # First sight. Nothing is pinned until a person says so.
        return render(
            request,
            "agents/_check.html",
            {"profile": profile, "offer": {"line": line, "fingerprint": fingerprint}},
        )

    if line.strip() != profile.host_key.strip():
        message = (
            "This host is presenting a different key than the one you accepted. "
            "Nothing was sent to it. If you rebuilt the machine, forget the key "
            "below and check again; if you did not, stop and find out why."
        )
        profile.last_error = message
        profile.last_checked_at = datetime.now(UTC)
        db.commit()
        return render(
            request,
            "agents/_check.html",
            {
                "profile": profile,
                "error": message,
                "offer": {"line": line, "fingerprint": fingerprint, "changed": True},
            },
        )

    try:
        found = await ssh_service.check(ssh_service.spec_from(profile), profile.default_dir)
    except ExecError as exc:
        profile.last_error = exc.message
        profile.last_checked_at = datetime.now(UTC)
        db.commit()
        return render(
            request, "agents/_check.html", {"profile": profile, "error": exc.message}
        )

    profile.last_error = ""
    profile.last_checked_at = datetime.now(UTC)
    profile.server_info = {"system": found.get("system", ""), "cwd": found.get("cwd", "")}
    db.commit()
    return render(request, "agents/_check.html", {"profile": profile, "found": found})


@router.post("/api/agents/{profile_id}/accept")
async def accept_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
    """Pin the fingerprint that was just shown.

    The line is re-fetched rather than taken from the form: a value that made a
    round trip through a browser is not what should end up as the thing every
    future connection is checked against.
    """
    profile = _profile(db, user, profile_id)
    try:
        line, fingerprint = await ssh_service.capture_host_key(
            profile.host, profile.port, timeout=profile.connect_timeout
        )
    except ExecError as exc:
        return render(request, "agents/_check.html", {"profile": profile, "error": exc.message})

    profile.host_key = line
    profile.host_fingerprint = fingerprint
    profile.last_error = ""
    db.commit()
    log.info("%s pinned host key for %s (%s)", user.email, profile.name, fingerprint)

    return render(
        request,
        "agents/_check.html",
        {"profile": profile, "accepted": fingerprint},
    )


@router.post("/api/agents/{profile_id}/forget")
async def forget_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
    profile = _profile(db, user, profile_id)
    profile.host_key = ""
    profile.host_fingerprint = ""
    # Un-trusting a host has to reach the shell already open on it, or the one
    # connection that matters is the one this does not touch.
    await terminal_service.close_for_profile(profile.id)
    index_service.forget(profile.id)
    db.commit()
    return render(request, "agents/_check.html", {"profile": profile, "forgotten": True})


@router.post("/api/agents/{profile_id}/delete")
async def delete_profile(db: Db, user: RequiredUser, profile_id: str) -> Response:
    profile = _profile(db, user, profile_id)
    name = profile.name
    await terminal_service.close_for_profile(profile.id)
    index_service.forget(profile.id)
    db.delete(profile)
    db.commit()
    log.info("%s deleted ssh profile %s", user.email, name)
    return _back(f"Deleted {name}.")


@router.post("/api/agents/{profile_id}")
async def update_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
    profile = _profile(db, user, profile_id)
    form = await request.form()

    before = (profile.host, profile.port)
    _apply(profile, form)

    if problem := _problem(db, profile, user.id, existing_id=profile.id):
        db.rollback()
        return _detail(
            request, db, user, _profile(db, user, profile_id), is_new=False, error=problem
        )

    profile.password_encrypted = keep_or_replace(
        str(form.get("password") or ""), profile.password_encrypted
    )
    profile.private_key_encrypted = keep_or_replace(
        str(form.get("private_key") or ""), profile.private_key_encrypted
    )
    profile.key_passphrase_encrypted = keep_or_replace(
        str(form.get("key_passphrase") or ""), profile.key_passphrase_encrypted
    )
    if profile.auth == AUTH_PASSWORD:
        profile.private_key_encrypted = ""
        profile.key_passphrase_encrypted = ""

    # A pinned key belongs to a host and a port. Moving either means this is a
    # different machine until proven otherwise, and silently keeping the old
    # key would be the one mistake this whole mechanism exists to prevent.
    if (profile.host, profile.port) != before and profile.host_key:
        profile.host_key = ""
        profile.host_fingerprint = ""
        log.info("%s moved ssh profile %s; its host key was forgotten", user.email, profile.name)

    # A shell already open holds its own connection and would not notice any of
    # this. `session.profile_for` re-checks the profile on every reply, so the
    # model stops at once; without the line below, "I disabled that connection"
    # would simply not be true of the terminal on screen.
    if not profile.enabled or not profile.host_key or (profile.host, profile.port) != before:
        await terminal_service.close_for_profile(profile.id)
    index_service.forget(profile.id)

    db.commit()
    return RedirectResponse(
        f"/agents/{profile.id}?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
    )
