# retoor <retoor@molodetz.nl>

import json
import shlex

import pytest

from devplacepy.content import can_manage_workspace, can_open_workspace
from devplacepy.database import get_table, init_db, set_setting
from devplacepy.services.containers import activity, api, store
from devplacepy.services.containers.workspace import (
    editor,
    flags,
    naming,
    provision,
    quota,
    tunnels,
)
from devplacepy.services.containers.workspace.provision import WorkspaceError
from tests.conftest import BASE_URL, run_async

OWNER = "user-owner"
OTHER = "user-other"
# A workspace route that answers wrongly must fail the test, never wedge the serial suite.
HTTP_TIMEOUT = 30


@pytest.fixture(autouse=True)
def _workspace_db():
    init_db()
    set_setting("workspace_enabled", "1")
    yield
    for table in (
        "instances",
        "tunnels",
        "workspace_flags",
        "workspace_quota_rules",
        "workspace_editor_prefs",
    ):
        get_table(table).delete()


def _project(uid: str = "proj-ws") -> dict:
    return {"uid": uid, "slug": "demo", "title": "Demo", "user_uid": OWNER}


def _instance(**overrides) -> dict:
    row = {
        "project_uid": "proj-ws",
        "name": "ws-demo",
        "status": "running",
        "desired_state": "running",
        "is_workspace": 1,
        "workspace_owner_uid": OWNER,
        "tunnel_name": naming.generate(),
        "ports_json": '[{"host": 20500, "container": 8080, "proto": "tcp"}]',
    }
    row.update(overrides)
    return store.create_instance(row)


def test_open_workspace_requires_enabled_setting():
    set_setting("workspace_enabled", "0")
    try:
        assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False
    finally:
        set_setting("workspace_enabled", "1")
    assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is True


def test_guest_can_never_open_workspace():
    assert can_open_workspace(_project(), None) is False
    assert can_open_workspace(_project(), {}) is False


def test_non_owner_member_cannot_open_workspace():
    assert can_open_workspace(_project(), {"uid": OTHER, "role": "Member"}) is False


def test_admin_can_open_any_project_workspace():
    assert can_open_workspace(_project(), {"uid": OTHER, "role": "Admin"}) is True


def test_owner_manages_own_workspace_without_admin():
    instance = _instance()
    assert can_manage_workspace(instance, _project(), {"uid": OWNER, "role": "Member"})
    assert not can_manage_workspace(
        instance, _project(), {"uid": OTHER, "role": "Member"}
    )


def test_create_or_resume_is_idempotent():
    project = _project()
    user = {"uid": OWNER, "username": "owner"}
    first = run_async(provision.ensure(project, user))
    second = run_async(provision.ensure(project, user))
    assert first["uid"] == second["uid"]
    assert provision.count_for_owner(OWNER) == 1


def test_workspace_quota_blocks_beyond_limit():
    set_setting("workspace_max_per_user", "1")
    try:
        user = {"uid": OWNER, "username": "owner"}
        run_async(provision.ensure(_project("p-a"), user))
        with pytest.raises(WorkspaceError):
            run_async(provision.ensure(_project("p-b"), user))
    finally:
        set_setting("workspace_max_per_user", "2")


def test_workspace_quota_does_not_apply_to_an_admin_owner(app_server, seeded_db):
    admin = _seeded_user("alice_test")
    set_setting("workspace_max_per_user", "1")
    try:
        run_async(provision.ensure(_project("p-admin-a"), admin))
        run_async(provision.ensure(_project("p-admin-b"), admin))
        assert provision.count_for_owner(admin["uid"]) == 2
    finally:
        set_setting("workspace_max_per_user", "2")


def test_tunnel_revives_rather_than_duplicates():
    instance = _instance()
    first = tunnels.create(instance, "web", 8080, OWNER)
    tunnels.soft_delete(first["uid"], OWNER)
    assert tunnels.count_for_instance(instance["uid"]) == 0
    revived = tunnels.create(instance, "web", 8080, OWNER)
    assert revived["uid"] == first["uid"]
    assert tunnels.count_for_instance(instance["uid"]) == 1


def test_tunnel_hostname_is_a_single_dns_label():
    instance = _instance()
    row = tunnels.create(instance, "web", 3000, OWNER)
    host = row["hostname"]
    suffix = "." + naming.domain()
    assert host.endswith(suffix)
    label = host[: -len(suffix)]
    assert "." not in label
    assert naming.is_valid_label(label)


def test_suspend_stops_tunnels_without_deleting_them():
    instance = _instance()
    tunnels.create(instance, "web", 8080, OWNER)
    provision.suspend(instance, "admin-uid", "abuse")
    rows = tunnels.list_for_instance(instance["uid"])
    assert rows and rows[0]["status"] == "suspended"
    refreshed = store.get_instance(instance["uid"])
    assert refreshed["suspended_at"]
    provision.unsuspend(refreshed)
    assert tunnels.list_for_instance(instance["uid"])[0]["status"] == "pending"


def test_suspended_workspace_cannot_resume():
    instance = _instance()
    provision.suspend(instance, "admin-uid", "abuse")
    with pytest.raises(WorkspaceError):
        provision.resume(store.get_instance(instance["uid"]))


def test_flags_are_idempotent_while_open():
    instance = _instance()
    first = flags.raise_flag(instance, flags.KIND_EGRESS, "warn", "a", 1.0, 0.5)
    second = flags.raise_flag(instance, flags.KIND_EGRESS, "warn", "b", 2.0, 0.5)
    assert first["uid"] == second["uid"]
    assert len(flags.list_flags(instance_uid=instance["uid"])) == 1
    flags.clear_flag(instance["uid"], flags.KIND_EGRESS, "admin")
    assert flags.list_flags(instance_uid=instance["uid"]) == []


def test_activity_accumulates_egress_and_requests():
    instance = _instance()
    activity.forget(instance["uid"])
    for _ in range(3):
        activity.touch(instance["uid"], egress_bytes=100)
    activity.flush(instance["uid"])
    row = store.get_instance(instance["uid"])
    assert row["egress_bytes"] == 300
    assert row["request_count"] == 3
    assert row["last_active_at"]


def test_quota_rule_overrides_only_its_owner():
    get_table("workspace_quota_rules").insert(
        {
            "uid": "rule-x",
            "owner_kind": "user",
            "owner_id": OWNER,
            "label": "power",
            "max_workspaces": 7,
            "deleted_at": None,
            "deleted_by": None,
        }
    )
    assert quota.resolve(OWNER).max_workspaces == 7
    assert quota.resolve(OTHER).max_workspaces == quota.resolve().max_workspaces


def test_workspace_env_contract_is_complete():
    from devplacepy.services.containers import api

    instance = _instance()
    env = api.workspace_env(instance, "https://example.test")
    for key in (
        "DEVPLACE_WORKSPACE",
        "DEVPLACE_WORKSPACE_UID",
        "DEVPLACE_TUNNEL_NAME",
        "DEVPLACE_TUNNEL_DOMAIN",
        "DEVPLACE_TUNNEL_MANIFEST",
        "VSCODE_PROXY_URI",
        "DEVPLACE_EDITOR_PORT",
        "DEVPLACE_QUOTA_DISK_MB",
        "DEVPLACE_RETENTION_DAYS",
    ):
        assert key in env, key
    assert all(isinstance(value, str) for value in env.values())
    assert "{{port}}" in env["VSCODE_PROXY_URI"]


def test_non_workspace_instance_gets_no_workspace_env():
    from devplacepy.services.containers import api

    env = api.workspace_env({"is_workspace": 0}, "https://example.test")
    assert env == {"DEVPLACE_WORKSPACE": ""}


def test_devii_workspace_tools_are_role_gated():
    from devplacepy.services.devii.registry import CATALOG

    names = {a.name for a in CATALOG.actions if a.handler == "workspace"}
    admin_only = {
        a.name for a in CATALOG.actions if a.handler == "workspace" and a.requires_admin
    }
    guest = {s["function"]["name"] for s in CATALOG.tool_schemas_for(False, False)}
    member = {s["function"]["name"] for s in CATALOG.tool_schemas_for(True, False)}
    admin = {s["function"]["name"] for s in CATALOG.tool_schemas_for(True, True)}
    assert not (names & guest)
    assert not ((names & member) & admin_only)
    assert names <= admin


def _idle_for(minutes: float) -> str:
    from datetime import datetime, timedelta, timezone

    return (
        datetime.now(timezone.utc) - timedelta(minutes=minutes)
    ).isoformat()


def _lifecycle(rows, stop="auto", delete="auto"):
    from devplacepy.services.containers.workspace_service import WorkspaceService

    WorkspaceService()._advance_lifecycle(
        rows, {"workspace_auto_stop": stop, "workspace_auto_delete": delete}
    )


def _reload(uid: str) -> dict:
    return store.get_instance(uid)


def test_resuming_restarts_the_idle_clock():
    instance = _instance(status="stopped", desired_state="stopped")
    store.update_instance(instance["uid"], {"last_active_at": _idle_for(600)})
    resumed = provision.resume(_reload(instance["uid"]))
    assert resumed["desired_state"] == "running"

    running = dict(resumed, status="running")
    _lifecycle([running])
    assert _reload(instance["uid"])["desired_state"] == "running"


def test_idle_workspace_is_warned_then_stopped():
    instance = _instance(status="running", desired_state="running")
    store.update_instance(instance["uid"], {"last_active_at": _idle_for(50)})
    _lifecycle([_reload(instance["uid"])])
    warned = _reload(instance["uid"])
    assert warned["idle_warned_at"]
    assert warned["desired_state"] == "running"

    store.update_instance(instance["uid"], {"last_active_at": _idle_for(90)})
    _lifecycle([_reload(instance["uid"])])
    stopped = _reload(instance["uid"])
    assert stopped["desired_state"] == "stopped"
    assert not stopped["idle_warned_at"]


def test_fresh_workspace_is_left_alone():
    instance = _instance(status="running", desired_state="running")
    store.update_instance(instance["uid"], {"last_active_at": _idle_for(1)})
    _lifecycle([_reload(instance["uid"])])
    fresh = _reload(instance["uid"])
    assert fresh["desired_state"] == "running"
    assert not fresh["idle_warned_at"]


def test_auto_stop_off_never_stops_an_idle_workspace():
    instance = _instance(status="running", desired_state="running")
    store.update_instance(instance["uid"], {"last_active_at": _idle_for(900)})
    _lifecycle([_reload(instance["uid"])], stop="off")
    assert _reload(instance["uid"])["desired_state"] == "running"


def test_suspended_workspace_is_skipped_entirely():
    instance = _instance(status="running", desired_state="running")
    store.update_instance(
        instance["uid"],
        {
            "last_active_at": _idle_for(60 * 24 * 400),
            "suspended_at": "2026-01-01T00:00:00+00:00",
        },
    )
    _lifecycle([_reload(instance["uid"])])
    survivor = _reload(instance["uid"])
    assert survivor is not None
    assert survivor["desired_state"] == "running"


def test_workspace_without_activity_is_never_touched():
    instance = _instance(status="running", desired_state="running")
    store.update_instance(instance["uid"], {"last_active_at": ""})
    _lifecycle([_reload(instance["uid"])])
    assert _reload(instance["uid"])["desired_state"] == "running"


def test_stopped_workspace_past_retention_is_deleted_with_its_tunnels():
    instance = _instance(status="stopped", desired_state="stopped")
    tunnels.create(instance, "web", 8080, OWNER)
    store.update_instance(
        instance["uid"], {"last_active_at": _idle_for(60 * 24 * 20)}
    )
    _lifecycle([_reload(instance["uid"])])
    assert _reload(instance["uid"]) is None
    for row in tunnels.list_for_instance(instance["uid"]):
        assert row["status"] == tunnels.STATUS_SUSPENDED


def test_running_workspace_is_never_deleted_by_retention():
    instance = _instance(status="running", desired_state="running")
    store.update_instance(
        instance["uid"], {"last_active_at": _idle_for(60 * 24 * 400)}
    )
    _lifecycle([_reload(instance["uid"])], stop="off")
    assert _reload(instance["uid"]) is not None


def test_auto_delete_off_keeps_an_expired_workspace():
    instance = _instance(status="stopped", desired_state="stopped")
    store.update_instance(
        instance["uid"], {"last_active_at": _idle_for(60 * 24 * 400)}
    )
    _lifecycle([_reload(instance["uid"])], delete="off")
    assert _reload(instance["uid"]) is not None


def test_advance_lifecycle_never_touches_an_admin_owned_workspace(app_server, seeded_db):
    admin = _seeded_user("alice_test")
    instance = _instance(
        status="running", desired_state="running", workspace_owner_uid=admin["uid"]
    )
    store.update_instance(
        instance["uid"], {"last_active_at": _idle_for(60 * 24 * 400)}
    )
    _lifecycle([_reload(instance["uid"])])
    survivor = _reload(instance["uid"])
    assert survivor is not None
    assert survivor["desired_state"] == "running"
    assert not survivor["idle_warned_at"]


def test_opening_a_workspace_publishes_the_editor_tunnel_automatically():
    project = _project()
    user = {"uid": OWNER, "username": "owner"}
    instance = run_async(provision.ensure(project, user))
    rows = tunnels.list_for_instance(instance["uid"])
    assert len(rows) == 1
    editor = rows[0]
    assert editor["container_port"] == api.EDITOR_DEFAULT_PORT
    assert editor["hostname"].startswith(f"{api.EDITOR_DEFAULT_PORT}-")
    assert editor["status"] == tunnels.STATUS_PENDING
    assert provision.is_editor_tunnel(instance, editor)
    assert instance["editor_password"]


def test_active_editor_tunnel_notifies_the_owner_with_url_and_password():
    from devplacepy.services.containers.workspace import certs

    project = _project()
    instance = run_async(provision.ensure(project, {"uid": OWNER, "username": "o"}))
    row = tunnels.list_for_instance(instance["uid"])[0]
    try:
        certs.announce(row, row["hostname"])
        sent = list(
            get_table("notifications").find(
                user_uid=OWNER, type="workspace", order_by=["-id"]
            )
        )
        assert sent, "owner was not notified"
        message = sent[0]["message"]
        assert f"https://{row['hostname']}" in message
        assert instance["editor_password"] in message
        assert sent[0]["target_url"].endswith("/workspace")
    finally:
        get_table("notifications").delete(user_uid=OWNER)


def test_editor_password_is_pronounceable_and_eight_chars():
    seen = {api.generate_editor_password() for _ in range(50)}
    assert len(seen) > 40
    for password in seen:
        assert len(password) == 8
        assert password.isalpha() and password.islower()
        for index, char in enumerate(password):
            expected = (
                api.EDITOR_PASSWORD_CONSONANTS
                if index % 2 == 0
                else api.EDITOR_PASSWORD_VOWELS
            )
            assert char in expected


def test_editor_runs_with_password_auth_and_an_eight_char_secret():
    from devplacepy.services.containers import api

    instance = _instance()
    password = api.ensure_editor_password(instance)
    assert len(password) == api.EDITOR_PASSWORD_LENGTH == 8
    assert password.isalnum()

    row = store.get_instance(instance["uid"])
    command = editor.argv(row, editor.resolve(OWNER, row))
    assert "--auth" in command
    assert command[command.index("--auth") + 1] == "password"
    assert "none" not in command


def test_editor_password_is_stable_and_reaches_the_container_env():
    from devplacepy.services.containers import api

    instance = _instance()
    first = api.ensure_editor_password(instance)
    again = api.ensure_editor_password(store.get_instance(instance["uid"]))
    assert first == again

    env = api.pravda_env(store.get_instance(instance["uid"]))
    assert env["PASSWORD"] == first


def test_every_workspace_launch_gets_a_password_even_if_never_provisioned():
    from devplacepy.services.containers import api

    instance = _instance()
    store.update_instance(instance["uid"], {"editor_password": ""})
    spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
    assert len(spec.env["PASSWORD"]) == 8
    assert store.get_instance(instance["uid"])["editor_password"] == spec.env["PASSWORD"]


def test_non_workspace_instance_never_gets_an_editor_password():
    from devplacepy.services.containers import api

    instance = _instance(is_workspace=0)
    spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
    assert not spec.env.get("PASSWORD")


def test_awaiting_certificate_only_returns_pending_present_tunnels():
    instance = _instance()
    tunnels.create(instance, "web", 8080, OWNER)
    tunnels.create(instance, "api", 9090, OWNER)
    active = tunnels.list_for_instance(instance["uid"])[0]
    tunnels.update(active["uid"], {"status": tunnels.STATUS_ACTIVE})
    waiting = [row["uid"] for row in tunnels.awaiting_certificate()]
    assert active["uid"] not in waiting
    assert len(waiting) == 1

    absent = tunnels.get(waiting[0])
    tunnels.mark_absent(absent["uid"])
    assert not tunnels.awaiting_certificate()


def test_certificate_phase_marks_active_on_success_and_failed_on_error(monkeypatch):
    from devplacepy.services.containers.workspace import certs
    from devplacepy.services.containers.workspace_service import WorkspaceService

    instance = _instance()
    tunnels.create(instance, "web", 8080, OWNER)
    row = tunnels.awaiting_certificate()[0]
    service = WorkspaceService()

    monkeypatch.setattr(certs, "configured", lambda: True)

    async def _ok(hostname):
        return None

    monkeypatch.setattr(certs, "issue", _ok)
    run_async(service._issue_tunnel_certificates())
    assert tunnels.get(row["uid"])["status"] == tunnels.STATUS_ACTIVE

    tunnels.update(row["uid"], {"status": tunnels.STATUS_PENDING})

    async def _boom(hostname):
        raise certs.CertError("molohttp cert issue failed (503): upstream down")

    monkeypatch.setattr(certs, "issue", _boom)
    run_async(service._issue_tunnel_certificates())
    failed = tunnels.get(row["uid"])
    assert failed["status"] == tunnels.STATUS_FAILED
    assert "503" in failed["last_error"]


def test_certificate_phase_is_a_noop_without_molohttp_credentials(monkeypatch):
    from devplacepy.services.containers.workspace import certs
    from devplacepy.services.containers.workspace_service import WorkspaceService

    instance = _instance()
    tunnels.create(instance, "web", 8080, OWNER)
    row = tunnels.awaiting_certificate()[0]
    monkeypatch.setattr(certs, "configured", lambda: False)
    run_async(WorkspaceService()._issue_tunnel_certificates())
    assert tunnels.get(row["uid"])["status"] == tunnels.STATUS_PENDING


def test_workspace_controller_always_returns_a_json_string():
    from devplacepy.services.devii.registry import CATALOG
    from devplacepy.services.devii.workspace.controller import WorkspaceController

    controller = WorkspaceController("user", OWNER, admin=True)
    names = [a.name for a in CATALOG.actions if a.handler == "workspace"]
    assert names
    for name in names + ["workspace_does_not_exist"]:
        result = run_async(controller.dispatch(name, {}))
        assert isinstance(result, str), f"{name} returned {type(result).__name__}"
        json.loads(result)


def test_every_confirm_gated_tool_declares_a_confirm_param():
    from devplacepy.services.devii.actions.dispatcher import CONFIRM_REQUIRED
    from devplacepy.services.devii.registry import CATALOG

    for action in CATALOG.actions:
        if action.handler != "workspace" or action.name not in CONFIRM_REQUIRED:
            continue
        assert any(p.name == "confirm" for p in action.params), action.name


def test_tunnel_host_detection_never_matches_the_site():
    assert naming.is_tunnel_host("abc." + naming.domain())
    assert not naming.is_tunnel_host("pravda.education")
    assert not naming.is_tunnel_host("")


def _seeded_user(username: str) -> dict:
    from devplacepy.database import db, refresh_snapshot

    refresh_snapshot()
    rows = list(
        db.query(
            "SELECT uid, username, api_key FROM users WHERE username = :username",
            username=username,
        )
    )
    assert rows, f"user {username} was not created by the server"
    return rows[0]


def _fresh_member() -> dict:
    import time as _time

    import requests

    name = f"wsmem{int(_time.time() * 1000)}"
    requests.post(
        f"{BASE_URL}/auth/signup",
        data={
            "username": name,
            "email": f"{name}@t.dev",
            "password": "secret123",
            "confirm_password": "secret123",
            "birth_date": "1990-01-01",
            "accept_terms": "1",
        },
        allow_redirects=True,
        timeout=HTTP_TIMEOUT,
    )
    return _seeded_user(name)


def _http_project(owner_uid: str, title: str) -> dict:
    from devplacepy.utils import generate_uid, make_combined_slug

    uid = generate_uid()
    row = {
        "uid": uid,
        "user_uid": owner_uid,
        "title": title,
        "description": "workspace host project",
        "slug": make_combined_slug(title, uid),
        "project_type": "software",
        "status": "In Development",
        "platforms": "",
        "is_private": 0,
        "read_only": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "deleted_at": None,
        "deleted_by": None,
    }
    get_table("projects").insert(row)
    _CREATED_PROJECTS.append(uid)
    return row


_CREATED_PROJECTS: list = []


@pytest.fixture(autouse=True)
def _drop_created_projects():
    yield
    while _CREATED_PROJECTS:
        get_table("projects").delete(uid=_CREATED_PROJECTS.pop())


def _await_workspaces_enabled(slug: str, key: str) -> bool:
    import time as _time

    import requests

    deadline = _time.time() + 10.0
    headers = {"Accept": "application/json", "X-API-KEY": key}
    while _time.time() < deadline:
        body = requests.get(
            f"{BASE_URL}/projects/{slug}", headers=headers, timeout=HTTP_TIMEOUT
        ).json()
        if body.get("viewer_can_workspace"):
            return True
        _time.sleep(0.2)
    return False


def _post(path: str, key: str, **kwargs):
    import requests

    return requests.post(
        f"{BASE_URL}{path}",
        headers={"Accept": "application/json", "X-API-KEY": key},
        timeout=HTTP_TIMEOUT,
        **kwargs,
    )


def test_opening_a_workspace_over_quota_answers_400_not_500(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    limit = quota.resolve(owner["uid"]).max_workspaces
    for index in range(limit):
        _instance(
            project_uid=f"proj-quota-{index}",
            name=f"ws-quota-{index}",
            workspace_owner_uid=owner["uid"],
        )
    project = _http_project(owner["uid"], "WS Quota Http")
    assert _await_workspaces_enabled(project["slug"], owner["api_key"])

    response = _post(f"/projects/{project['slug']}/workspace", owner["api_key"])

    assert response.status_code == 400, response.text[:400]
    assert "limit" in response.json()["error"]["message"]
    assert provision.count_for_owner(owner["uid"]) == limit


def test_opening_a_workspace_under_quota_is_not_refused(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    project = _http_project(owner["uid"], "WS Room Http")
    assert _await_workspaces_enabled(project["slug"], owner["api_key"])

    response = _post(f"/projects/{project['slug']}/workspace", owner["api_key"])

    assert response.status_code != 500, response.text[:400]
    assert response.status_code != 400 or "limit" not in response.json()["error"]["message"]


def test_tunnel_creation_rejects_a_zero_port_with_400(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    project = _http_project(owner["uid"], "WS Port Http")
    _instance(project_uid=project["uid"], workspace_owner_uid=owner["uid"])
    assert _await_workspaces_enabled(project["slug"], owner["api_key"])

    response = _post(
        f"/projects/{project['slug']}/workspace/tunnels",
        owner["api_key"],
        data={"label": "web", "container_port": "0"},
    )

    assert response.status_code == 400, response.text[:400]
    assert "container_port" in response.json()["error"]["message"]


def test_tunnel_limit_answers_400(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    project = _http_project(owner["uid"], "WS Tunnel Cap Http")
    instance = _instance(project_uid=project["uid"], workspace_owner_uid=owner["uid"])
    limit = quota.resolve(owner["uid"], instance).max_tunnels
    for port in range(9000, 9000 + limit):
        tunnels.create(instance, f"t{port}", port, owner["uid"])
    assert _await_workspaces_enabled(project["slug"], owner["api_key"])

    response = _post(
        f"/projects/{project['slug']}/workspace/tunnels",
        owner["api_key"],
        data={"label": "over", "container_port": "9999"},
    )

    assert response.status_code == 400, response.text[:400]
    assert "tunnel limit reached" in response.json()["error"]["message"]
    assert tunnels.count_for_instance(instance["uid"]) == limit


def test_editor_proxy_denies_a_stranger_with_403(app_server, seeded_db):
    import requests

    owner = _seeded_user("bob_test")
    stranger = _fresh_member()
    project = _http_project(owner["uid"], "WS Editor Http")
    instance = _instance(project_uid=project["uid"], workspace_owner_uid=owner["uid"])

    response = requests.get(
        f"{BASE_URL}/projects/{project['slug']}/containers/instances/{instance['uid']}/code/",
        headers={"Accept": "application/json", "X-API-KEY": stranger["api_key"]},
        timeout=HTTP_TIMEOUT,
    )

    assert response.status_code == 403, response.text[:400]


def test_admin_suspend_without_a_reason_answers_400(app_server, seeded_db):
    admin = _seeded_user("alice_test")
    owner = _seeded_user("bob_test")
    project = _http_project(owner["uid"], "WS Suspend Http")
    instance = _instance(project_uid=project["uid"], workspace_owner_uid=owner["uid"])

    response = _post(
        f"/admin/workspaces/{instance['uid']}/suspend",
        admin["api_key"],
        data={"reason": "   "},
    )

    assert response.status_code == 400, response.text[:400]
    assert "reason" in response.json()["error"]["message"]
    assert not store.get_instance(instance["uid"])["suspended_at"]


def test_admin_quota_rule_without_an_owner_answers_400(app_server, seeded_db):
    admin = _seeded_user("alice_test")

    response = _post(
        "/admin/workspaces/quota", admin["api_key"], data={"owner_id": ""}
    )

    assert response.status_code == 400, response.text[:400]
    assert "owner_id" in response.json()["error"]["message"]


def _certified(project: dict, monkeypatch, issue) -> dict:
    from devplacepy.services.containers.workspace import certs

    monkeypatch.setattr(certs, "configured", lambda: True)
    monkeypatch.setattr(certs, "issue", issue)

    async def _open_and_drain() -> dict:
        import asyncio

        instance = await provision.ensure(project, {"uid": OWNER, "username": "o"})
        pending = list(provision._pending_certificates)
        if pending:
            await asyncio.gather(*pending, return_exceptions=True)
        return instance

    return run_async(_open_and_drain())


def test_opening_a_workspace_certifies_its_editor_tunnel_without_waiting_for_a_tick(
    monkeypatch,
):
    ordered = []

    async def _ok(hostname: str) -> None:
        ordered.append(hostname)

    instance = _certified(_project(), monkeypatch, _ok)

    row = tunnels.list_for_instance(instance["uid"])[0]
    assert ordered == [row["hostname"]], "the editor certificate was not ordered eagerly"
    assert row["status"] == tunnels.STATUS_ACTIVE
    assert not tunnels.awaiting_certificate()


def test_eagerly_certified_editor_tunnel_still_announces_url_and_password(monkeypatch):
    async def _ok(hostname: str) -> None:
        return None

    instance = _certified(_project(), monkeypatch, _ok)
    row = tunnels.list_for_instance(instance["uid"])[0]
    try:
        sent = list(
            get_table("notifications").find(
                user_uid=OWNER, type="workspace", order_by=["-id"]
            )
        )
        assert sent, "owner was not notified after eager issuance"
        assert f"https://{row['hostname']}" in sent[0]["message"]
        assert instance["editor_password"] in sent[0]["message"]
    finally:
        get_table("notifications").delete(user_uid=OWNER)


def test_an_eager_certificate_failure_leaves_the_tunnel_failed_not_pending(monkeypatch):
    from devplacepy.services.containers.workspace import certs

    async def _boom(hostname: str) -> None:
        raise certs.CertError("molohttp cert issue failed (503): upstream down")

    instance = _certified(_project(), monkeypatch, _boom)

    row = tunnels.list_for_instance(instance["uid"])[0]
    assert row["status"] == tunnels.STATUS_FAILED
    assert "upstream down" in row["last_error"]
    assert not tunnels.awaiting_certificate()


def test_no_certificate_is_ordered_when_molohttp_is_unconfigured(monkeypatch):
    from devplacepy.services.containers.workspace import certs

    ordered = []

    async def _ok(hostname: str) -> None:
        ordered.append(hostname)

    monkeypatch.setattr(certs, "configured", lambda: False)
    monkeypatch.setattr(certs, "issue", _ok)
    instance = run_async(provision.ensure(_project(), {"uid": OWNER, "username": "o"}))

    assert ordered == []
    row = tunnels.list_for_instance(instance["uid"])[0]
    assert row["status"] == tunnels.STATUS_PENDING
    assert [r["uid"] for r in tunnels.awaiting_certificate()] == [row["uid"]]


def _editor_workspace(owner: dict, title: str):
    project = _http_project(owner["uid"], title)
    instance = store.create_instance(
        {
            "project_uid": project["uid"],
            "name": "ws-editor-http",
            "status": "running",
            "desired_state": "running",
            "is_workspace": 1,
            "workspace_owner_uid": owner["uid"],
            "tunnel_name": naming.generate(),
            "ports_json": '[{"host": 20911, "container": 8080, "proto": "tcp"}]',
        }
    )
    return project, instance


def _get(path: str, key: str):
    import requests

    return requests.get(
        f"{BASE_URL}{path}",
        headers={"Accept": "application/json", "X-API-KEY": key},
        timeout=HTTP_TIMEOUT,
    )


def test_editor_profile_reads_back_over_http(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    project, _ = _editor_workspace(owner, "WS Editor Read")
    slug = project["slug"]
    assert _await_workspaces_enabled(slug, owner["api_key"])

    body = _get(f"/projects/{slug}/workspace/editor", owner["api_key"]).json()
    assert body["editor"]["theme"] == editor.DEFAULTS["theme"]
    assert body["editor"]["sources"]["theme"] == editor.SOURCE_SITE
    assert body["editor"]["cpu_millicores"] == quota.resolve(owner["uid"]).cpu_millicores
    assert body["restart_required"] is False


def test_editor_preferences_save_and_reset_over_http(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    project, _ = _editor_workspace(owner, "WS Editor Write")
    slug = project["slug"]
    assert _await_workspaces_enabled(slug, owner["api_key"])
    try:
        saved = _post(
            f"/projects/{slug}/workspace/editor",
            owner["api_key"],
            data={"theme": "devplace-light", "font_size": "21"},
        )
        assert saved.status_code == 200, saved.text
        profile = saved.json()["data"]["editor"]
        assert profile["theme"] == "devplace-light"
        assert profile["font_size"] == 21
        assert profile["sources"]["font_size"] == editor.SOURCE_USER

        reset = _post(
            f"/projects/{slug}/workspace/editor",
            owner["api_key"],
            data={"reset": "true"},
        )
        assert reset.status_code == 200, reset.text
        after = reset.json()["data"]["editor"]
        assert after["font_size"] == editor.DEFAULTS["font_size"]
        assert after["sources"]["font_size"] == editor.SOURCE_SITE
    finally:
        get_table("workspace_editor_prefs").delete(owner_id=owner["uid"])


def test_an_out_of_range_editor_value_is_refused_not_clamped(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    project, _ = _editor_workspace(owner, "WS Editor Range")
    slug = project["slug"]
    assert _await_workspaces_enabled(slug, owner["api_key"])

    refused = _post(
        f"/projects/{slug}/workspace/editor",
        owner["api_key"],
        data={"font_size": "500"},
    )
    assert refused.status_code == 422, refused.text


def test_editor_preferences_are_refused_to_a_non_owner(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    intruder = _fresh_member()
    project, _ = _editor_workspace(owner, "WS Editor Foreign")
    slug = project["slug"]

    denied = _get(f"/projects/{slug}/workspace/editor", intruder["api_key"])
    assert denied.status_code in (403, 404), denied.text


def test_editor_preferences_reject_a_guest_with_401_not_422(app_server, seeded_db):
    import requests

    owner = _seeded_user("bob_test")
    project, _ = _editor_workspace(owner, "WS Editor Guest")
    guest = requests.post(
        f"{BASE_URL}/projects/{project['slug']}/workspace/editor",
        headers={"Accept": "application/json"},
        data={},
        timeout=HTTP_TIMEOUT,
    )
    assert guest.status_code == 401, guest.status_code


def test_a_saved_preference_asks_for_a_restart_while_running(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    project, instance = _editor_workspace(owner, "WS Editor Restart")
    slug = project["slug"]
    assert _await_workspaces_enabled(slug, owner["api_key"])
    try:
        editor.seed_state(instance, editor.resolve(owner["uid"], instance))
        body = _post(
            f"/projects/{slug}/workspace/editor",
            owner["api_key"],
            data={"font_size": "29"},
        ).json()
        assert body["data"]["restart_required"] is True
    finally:
        get_table("workspace_editor_prefs").delete(owner_id=owner["uid"])


def test_the_run_spec_applies_the_quota_cpu_and_memory_to_a_workspace():
    from devplacepy.services.containers import api

    instance = _instance(editor_port=api.EDITOR_DEFAULT_PORT)
    limits = quota.resolve(OWNER, store.get_instance(instance["uid"]))
    spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
    assert spec.cpu_limit == limits.cpu_limit()
    assert spec.mem_limit == limits.mem_limit()
    assert spec.command[0] == "/bin/sh"
    assert spec.command[1] == "-c"
    script = spec.command[2]
    assert "code-server" in script
    assert "--app-name" in script
    assert "--disable-workspace-trust" in script
    assert editor.ENV_EXPORT_FILE in script
    assert shlex.split(script)[-1] == "/app"


def test_the_run_spec_seeds_the_editor_state_and_stamps_a_boot_marker(monkeypatch, tmp_path):
    from devplacepy import config
    from devplacepy.services.containers import api

    monkeypatch.setattr(config, "WORKSPACE_STATE_DIR", tmp_path / "state")
    instance = _instance()
    spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")

    marker = store.get_instance(instance["uid"])["boot_marker"]
    assert marker
    assert spec.env["DEVPLACE_CONTAINER_BOOT"] == marker
    assert spec.env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME
    seeded = tmp_path / "state" / instance["uid"] / "data" / "User" / "settings.json"
    assert seeded.exists()


def test_a_non_workspace_instance_keeps_its_own_limits():
    from devplacepy.services.containers import api

    instance = _instance(is_workspace=0, cpu_limit="0.5", mem_limit="256m")
    spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
    assert spec.cpu_limit == "0.5"
    assert spec.mem_limit == "256m"
    assert not spec.env.get("DEVPLACE_EDITOR_APP_NAME")


def test_a_workspace_without_an_editor_port_still_gets_its_size():
    from devplacepy.services.containers import api

    instance = _instance()
    limits = quota.resolve(OWNER, store.get_instance(instance["uid"]))
    spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
    assert spec.cpu_limit == limits.cpu_limit()
    assert spec.command == ["sleep", "infinity"]


def test_publish_tunnel_records_that_certificates_are_not_configured():
    instance = _instance()
    row = provision.publish_tunnel(instance, "web", 3000, OWNER)
    assert row["status"] == tunnels.STATUS_PENDING
    assert row["last_error"] == provision.CERT_UNCONFIGURED


def test_publish_tunnel_enforces_the_tunnel_quota():
    set_setting("workspace_max_tunnels", "1")
    try:
        instance = _instance()
        provision.publish_tunnel(instance, "web", 3000, OWNER)
        with pytest.raises(WorkspaceError):
            provision.publish_tunnel(instance, "api", 3001, OWNER)
    finally:
        set_setting("workspace_max_tunnels", "5")


def test_publish_tunnel_quota_does_not_apply_to_an_admin_owner(app_server, seeded_db):
    admin = _seeded_user("alice_test")
    set_setting("workspace_max_tunnels", "1")
    try:
        instance = _instance(workspace_owner_uid=admin["uid"])
        provision.publish_tunnel(instance, "web", 3100, admin["uid"])
        provision.publish_tunnel(instance, "api", 3101, admin["uid"])
        assert tunnels.count_for_instance(instance["uid"]) == 2
    finally:
        set_setting("workspace_max_tunnels", "5")


def test_publish_tunnel_refuses_a_port_outside_the_valid_range():
    instance = _instance()
    with pytest.raises(WorkspaceError):
        provision.publish_tunnel(instance, "web", 0, OWNER)


def test_tunnel_route_resolves_an_unpublished_port_through_the_container():
    from devplacepy.routers import tunnel as tunnel_router

    instance = _instance(container_ip="172.17.0.9", container_gateway="172.17.0.1")
    row = provision.publish_tunnel(instance, "web", 3000, OWNER)
    tunnels.update(row["uid"], {"status": tunnels.STATUS_ACTIVE})
    resolved, matched, host, port = tunnel_router.resolve(row["hostname"])
    assert resolved["uid"] == row["uid"]
    assert matched["uid"] == instance["uid"]
    assert (host, port) == ("172.17.0.9", 3000)


def test_republishing_a_live_tunnel_keeps_its_certificate():
    instance = _instance()
    row = provision.publish_tunnel(instance, "web", 3000, OWNER)
    tunnels.update(row["uid"], {"status": tunnels.STATUS_ACTIVE, "last_error": ""})
    again = provision.publish_tunnel(instance, "web", 3000, OWNER)
    assert again["uid"] == row["uid"]
    assert again["status"] == tunnels.STATUS_ACTIVE
    assert again["last_error"] == ""


def _editor_listener():
    import socket

    sock = socket.socket()
    sock.bind(("127.0.0.1", 0))
    sock.listen(8)
    return sock


def _workspace_json(slug: str, key: str) -> dict:
    import requests

    return requests.get(
        f"{BASE_URL}/projects/{slug}/workspace",
        headers={"Accept": "application/json", "X-API-KEY": key},
        timeout=HTTP_TIMEOUT,
    ).json()


def test_workspace_json_reports_the_phase_from_a_live_editor_probe(app_server, seeded_db):
    owner = _seeded_user("bob_test")
    project = _http_project(owner["uid"], "WS Phase Http")
    sock = _editor_listener()
    port = sock.getsockname()[1]
    try:
        _instance(
            project_uid=project["uid"],
            workspace_owner_uid=owner["uid"],
            editor_port=editor.EDITOR_DEFAULT_PORT,
            ports_json=(
                f'[{{"host": {port}, "container": {editor.EDITOR_DEFAULT_PORT}, '
                '"proto": "tcp"}]'
            ),
        )
        body = _workspace_json(project["slug"], owner["api_key"])
        assert body["workspace"]["phase"] == provision.PHASE_READY
        assert body["workspace"]["phase_label"] == "Ready"
        assert body["workspace"]["editor_ready"] is True
        assert body["workspace"]["owner_uid"] == owner["uid"]
        assert body["editor_url"].endswith("/code/")
    finally:
        sock.close()

    body = _workspace_json(project["slug"], owner["api_key"])
    assert body["workspace"]["phase"] == provision.PHASE_STARTING
    assert body["workspace"]["phase_label"] == "Starting"
    assert body["workspace"]["editor_ready"] is False
    assert body["workspace"]["status"] == "running"


def test_editor_proxy_refuses_a_stopped_workspace_as_plain_text(app_server, seeded_db):
    import requests

    owner = _seeded_user("bob_test")
    project = _http_project(owner["uid"], "WS Proxy Stopped")
    instance = _instance(
        project_uid=project["uid"],
        workspace_owner_uid=owner["uid"],
        status="stopped",
        desired_state="stopped",
    )

    response = requests.get(
        f"{BASE_URL}/projects/{project['slug']}/containers/instances/{instance['uid']}/code/",
        headers={"X-API-KEY": owner["api_key"]},
        timeout=HTTP_TIMEOUT,
    )

    assert response.status_code == 409, response.text[:400]
    assert response.headers["content-type"].startswith("text/plain")
    assert "not running" in response.text
