import json
import os
import platform
import secrets
import shutil
import subprocess
import time
import urllib.request
from pathlib import Path

from engine.stored import read_json, write_json
from typing import TypedDict
from engine.given import given
from engine.wording import slugged
from resources.base import Refused

TUNNEL_FILE = "sharing.json"
OWNED = "domain is owned by another user"
NAME_BYTES = 12
LOCAL_BIN = Path.home() / ".local" / "bin" / "tunler"
ARCHES = {"x86_64": "amd64", "aarch64": "arm64"}
DOWNLOAD_SECONDS = 60


def subdomain(root: Path) -> str:
    path = Path(root) / TUNNEL_FILE
    kept = {}
    if path.exists():
        unreadable = f"cannot read the tunnel address in {path}; the address has not changed"
        try:
            kept = json.loads(path.read_text())
        except (OSError, ValueError) as error:
            raise Refused(unreadable) from error
        if not isinstance(kept, dict):
            raise Refused(unreadable)
    if kept.get("subdomain"):
        return kept["subdomain"]
    return addressed(root, kept)


def addressed(root: Path, kept: dict) -> str:
    prefix = slugged(Path(root).resolve().parent.name, limit=20) or "journal"
    name = f"{prefix}-{secrets.token_hex(NAME_BYTES)}"
    write_json(Path(root) / TUNNEL_FILE, {**kept, "subdomain": name})
    return name


def refused_address(log: Path) -> bool:
    try:
        lines = log.read_text(errors="ignore").splitlines()[-4:]
    except OSError:
        return False
    return any(OWNED in line for line in lines)


def tunler() -> str:
    found = shutil.which("tunler")
    if found:
        return found
    return str(LOCAL_BIN) if LOCAL_BIN.is_file() else ""

STATUS_SECONDS = 5
STATUS_KEPT = 60
KEPT_STATUS: dict = {}


def tunler_status() -> dict:
    if time.time() - KEPT_STATUS.get("at", 0) < STATUS_KEPT:
        return KEPT_STATUS["status"]
    KEPT_STATUS.update(at=time.time(), status=asked_status())
    return KEPT_STATUS["status"]


class TunnelStatus(TypedDict):
    installed: bool
    logged_in: bool
    account: str
    host: str


class Login(TypedDict):
    connected: bool
    needs_master: bool
    error: str


def asked_status() -> TunnelStatus:
    command = tunler()
    if not command:
        return TunnelStatus(installed=False, logged_in=False, account="", host="")
    try:
        told = json.loads(subprocess.run([command, "status", "--json"], capture_output=True, text=True, timeout=STATUS_SECONDS).stdout or "{}")
    except (OSError, subprocess.TimeoutExpired, ValueError):
        told = {}
    return TunnelStatus(installed=True, logged_in=bool(told.get("logged_in") and told.get("auth_ok")), account=told.get("user") or told.get("email", ""),
                        host=told.get("host", ""))


LOGIN_SECONDS = 30


def ran(*words: str, hidden: dict | None = None) -> tuple[bool, str]:
    command = tunler()
    if not command:
        return False, "tunler isn't installed on this machine"
    try:
        done = subprocess.run([command, *words], capture_output=True, text=True, timeout=LOGIN_SECONDS, env={**os.environ, **(hidden or {})},
                              stdin=subprocess.DEVNULL)
    except (OSError, subprocess.TimeoutExpired) as error:
        return False, f"tunler {words[0]} did not finish: {error}"
    KEPT_STATUS.clear()
    return done.returncode == 0, (done.stdout if done.returncode == 0 else done.stderr or done.stdout).strip()


def log_in(host: str, username: str, password: str, master: str | None = None) -> Login:
    ok, said = ran("login", username, f"--host={host}", hidden=given(TUNLER_PASSWORD=password, TUNLER_MASTER_PASSWORD=master))
    if ok:
        return Login(connected=True, needs_master=False, error="")
    lines = said.splitlines() or ["tunler refused the login"]
    needs = "master password" in said.lower() and master is None
    return Login(connected=False, needs_master=needs, error=lines[0] if needs else lines[-1])


class TunlerVersion(TypedDict):
    current: str
    latest: str
    update_available: bool


def installed() -> str:
    ok, said = ran("version")
    return said.split()[-1] if ok and said else ""


def latest(host: str) -> str:
    try:
        with urllib.request.urlopen(f"https://{host}/_tunler/version", timeout=STATUS_SECONDS) as answer:
            return str(json.loads(answer.read()).get("version", ""))
    except (OSError, ValueError):
        return ""


def versions(host: str) -> TunlerVersion:
    ok, said = ran("update", "--check", "--json", f"--host={host}")
    try:
        told = json.loads(said) if ok else {}
    except ValueError:
        told = {}
    if "update_available" in told:
        return TunlerVersion(current=told.get("current", ""), latest=told.get("latest", ""), update_available=bool(told["update_available"]))
    current, newest = installed(), latest(host)
    return TunlerVersion(current=current, latest=newest, update_available=bool(current and newest and current != newest))


def install(host: str) -> str:
    machine = platform.machine().lower()
    build = f"tunler-{platform.system().lower()}-{ARCHES.get(machine, machine)}"
    part = LOCAL_BIN.with_name("tunler.part")
    LOCAL_BIN.parent.mkdir(parents=True, exist_ok=True)
    try:
        with urllib.request.urlopen(f"https://{host}/dl/{build}", timeout=DOWNLOAD_SECONDS) as answer:
            part.write_bytes(answer.read())
    except OSError as error:
        part.unlink(missing_ok=True)
        return f"tunler could not be downloaded: {error}"
    part.chmod(0o700)
    part.replace(LOCAL_BIN)
    return f"tunler {installed()} is installed"


def updated() -> str:
    ok, said = ran("update")
    if not ok:
        return said or "tunler did not update"
    return said.splitlines()[-1] if said else "tunler is up to date"


def log_out() -> str:
    ok, said = ran("logout")
    return "" if ok else said or "tunler did not log out"


def owned() -> list[str]:
    ok, said = ran("domains")
    return [line.strip() for line in said.splitlines() if line.strip()] if ok else []


def unclaim(domain: str, host: str) -> str:
    ok, said = ran("release", domain.removesuffix(f".{host}"))
    return "" if ok else said or f"tunler did not release {domain}"
======
import time
from pathlib import Path

from engine import runtime
from engine.keeper import ServiceSpec
from engine.package import entry
from engine.record import Record
from engine.services import BUILD, allocate, current_build, files_for
from features.sharing.controller import Shares
from features.sharing.tunnel import subdomain, tunler
from resources.base import Refused, SYSTEM

SERVER, TUNNEL = "sharing.server", "sharing.tunnel"
KEEP_UP: list = []


def open_shares(root: Path) -> list:
    now = time.time()
    shares = Shares(Record(root, runtime.env(root)), actor=SYSTEM)
    return [row for row in shares.summaries() if row.get("token") and row.get("approved") and not row["completed"] and not row["deleted"]
            and not (row.get("expires") and row["expires"] < now)]


def wanted(root: Path) -> bool:
    from features import FEATURES
    record = Record(root, runtime.env(root))
    sharing = "sharing" in FEATURES and FEATURES["sharing"].enabled(record)
    return (sharing and bool(open_shares(root))) or any(keep(root) for keep in KEEP_UP)


def share_services(root: Path, taken: set) -> list:
    if not wanted(root):
        return []
    port, blocked = allocate(root, SERVER, None, taken)
    taken.add(port)
    specs = [ServiceSpec(id=SERVER, plugin="sharing", service="server", run=[*entry("features.sharing.server"), str(root), str(port)],
                         cwd=str(Path(root).parent), port=port, blocked=blocked, url=f"http://127.0.0.1:{port}", env={BUILD: current_build(root)},
                         **files_for(root, SERVER))]
    command = tunler()
    if not command:
        return specs
    try:
        domain = subdomain(root)
    except Refused:
        return specs
    inspector, _ = allocate(root, TUNNEL, None, taken)
    taken.add(inspector)
    specs.append(ServiceSpec(id=TUNNEL, plugin="sharing", service="tunnel", cwd=str(Path(root).parent), port=inspector, url=f"http://127.0.0.1:{inspector}",
                             run=[command, str(port), f"--domain={domain}", f"--inspect={inspector}"],
                             env={BUILD: f"{current_build(root)}:{port}:{inspector}"}, **files_for(root, TUNNEL)))
    return specs
=====
25:from features.sharing.tunnel import TUNNEL_FILE, TunlerVersion, addressed, install, log_in, log_out, owned, subdomain, tunler_status, unclaim, updated, versions
45:def member_refs(row) -> list[str]:
51:def hashed(password: str) -> str:
56:def matches(password: str, kept: str) -> bool:
74:    def of(cls, comment, about: str, record, replies: tuple = ()) -> "SharedComment":
80:def scoped(text: str, scope: set[str]) -> str:
84:def status_of(url: str, wait: float = REACH_SECONDS) -> int:
94:def answers(url: str, wait: float = REACH_SECONDS) -> bool:
98:def reached(url: str, wait: float = REACH_SECONDS) -> bool:
102:def until(expires: str) -> float:
114:    def create(self, title: str, abstract: str = "", brief: str = "", expires: str = "7d", password: str = "", **data):
126:    def share_layout(self, name: str, layout: str, expires: str = "7d", once: bool = False):
138:    def _layout_opened(self, share) -> None:
142:    def approve(self, n: int):
147:    def ask(self, n: int, question: str, options: str):
155:    def _visitor_answer(self, share, n: int, name: str, choice: str):
180:    def agree(self, n: int, words: str) -> str:
189:    def _visitor_comment(self, share, ref: str, name: str, text: str):
208:    def _show_visitor_comment(self, record, comment, ref: str) -> None:
215:    def allow(self, n: int):
233:    def _shared_comments(self, share, scope: set[str]) -> list[SharedComment]:
238:        def about(refs: set[str]) -> list:
251:    def _ask_to_open(self, share, target) -> None:
261:    def opens(self, ref: str) -> list[str]:
269:    def tunnel(self) -> dict:
270:        return {**tunler_status(), "address": self._address()}
272:    def _address(self) -> str:
273:        return f"{subdomain(self.record.root)}.{self._host()}"
275:    def login(self, username: str, password: str, endpoint: str | None = None, master_password: str | None = None) -> dict:
276:        self._user_only("log tunler in")
277:        host = endpoint.strip() if endpoint else self._host()
278:        return {**log_in(host, username.strip(), password, master_password or None), **self.tunnel()}
280:    def logout(self) -> dict:
281:        self._user_only("log tunler out")
287:    def version(self) -> TunlerVersion:
288:        return versions(self._host())
290:    def install_tunler(self) -> str:
291:        self._user_only("install tunler")
292:        return install(self._host())
294:    def update_tunler(self) -> str:
295:        self._user_only("update tunler")
298:    def _host(self) -> str:
299:        return FEATURES["sharing"].setting(self.record, "host", "tunler.jessegall.nl")
301:    def domains(self) -> list[str]:
304:    def release(self, domain: str) -> list[str]:
305:        self._user_only("release a tunler domain")
306:        failed = unclaim(domain, self._host())
311:    def readdress(self) -> str:
320:    def _user_only(self, what: str) -> None:
324:    def reachable(self, n: int) -> dict:
327:    def answering(self) -> dict:
330:    def _answering(self, wait: float = REACH_SECONDS) -> bool:
333:    def _link(self, token: str) -> str:
336:    def _target(self, ref: str):
345:    def _by_token(self, token: str):
351:    def _home(self, share) -> Record:
354:    def _shared_row(self, share, ref: str):
358:    def _loaded_members(self, record: Record, row) -> list:
372:    def _members(self, share, collection) -> list:
375:    def _scope(self, share) -> set[str]:
src/features/phone/__init__.py
src/features/phone/controller.py
src/features/phone/details.py
src/features/phone/export.py
src/features/phone/feature.py
src/features/phone/places.py
src/features/phone/push.py
src/features/phone/resource.py
src/features/phone/routes.py
src/features/phone/test.py
src/features/sharing/tunnel.py
src/web/demo/PhoneBand.vue
src/web/demo/PhoneFrame.vue
src/web/demo/phoneBoot.js
src/web/dist/assets/phone-BNvvgh4e.css
src/web/dist/assets/phone-CXPtcESq.js
src/web/dist/phone-sw.js
src/web/dist/phone.html
src/web/phone.html
src/web/public/phone-sw.js
src/web/src/api/phone.js
src/web/src/composables/phones.js
src/web/src/layout/PhoneDialog.vue
src/web/src/layout/PhoneRow.vue
src/web/src/layout/ShareTunnel.vue
src/web/src/pages/SettingsTunnel.vue
src/web/src/pages/TunlerVersion.vue
src/web/src/pages/TunnelDomain.vue
src/web/src/phone/PhoneActions.vue
src/web/src/phone/PhoneAgent.vue
src/web/src/phone/PhoneAgentControls.vue
src/web/src/phone/PhoneAgentSheet.vue
src/web/src/phone/PhoneApp.vue
src/web/src/phone/PhoneAtWork.vue
src/web/src/phone/PhoneAtWorkChip.vue
src/web/src/phone/PhoneAtWorkRow.vue
src/web/src/phone/PhoneBoard.vue
src/web/src/phone/PhoneBoardList.vue
src/web/src/phone/PhoneBody.vue
src/web/src/phone/PhoneButtons.vue
src/web/src/phone/PhoneChevron.vue
src/web/src/phone/PhoneCommentSheet.vue
src/web/src/phone/PhoneComments.vue
src/web/src/phone/PhoneCompose.vue
src/web/src/phone/PhoneFiles.vue
src/web/src/phone/PhoneFold.vue
src/web/src/phone/PhoneHelperDetail.vue
src/web/src/phone/PhoneHold.vue
src/web/src/phone/PhoneHome.vue
src/web/src/phone/PhoneHomeBar.vue
src/web/src/phone/PhoneHomeOlder.vue
src/web/src/phone/PhoneHomeSending.vue
src/web/src/phone/PhoneMark.vue
src/web/src/phone/PhoneMeta.vue
src/web/src/phone/PhoneMissing.vue
src/web/src/phone/PhoneNeeds.vue
src/web/src/phone/PhoneNotices.vue
src/web/src/phone/PhoneNotify.vue
src/web/src/phone/PhoneParent.vue
src/web/src/phone/PhonePeer.vue
src/web/src/phone/PhonePermit.vue
src/web/src/phone/PhonePlaceDetail.vue
src/web/src/phone/PhonePlaceList.vue
src/web/src/phone/PhonePlaces.vue
src/web/src/phone/PhonePlanSheet.vue
src/web/src/phone/PhonePlanStrip.vue
src/web/src/phone/PhoneQuestion.vue
src/web/src/phone/PhoneQuote.vue
src/web/src/phone/PhoneReactions.vue
src/web/src/phone/PhoneReader.vue
src/web/src/phone/PhoneReaderApprove.vue
src/web/src/phone/PhoneReaderBar.vue
src/web/src/phone/PhoneReaderChips.vue
src/web/src/phone/PhoneReaderFacts.vue
src/web/src/phone/PhoneReaderPhases.vue
src/web/src/phone/PhoneReaderReview.vue
src/web/src/phone/PhoneSending.vue
src/web/src/phone/PhoneShareRow.vue
src/web/src/phone/PhoneShareSheet.vue
src/web/src/phone/PhoneSheet.vue
src/web/src/phone/PhoneSkeleton.vue
src/web/src/phone/PhoneSkeletonPage.vue
src/web/src/phone/PhoneSkeletonRows.vue
src/web/src/phone/PhoneStatus.vue
src/web/src/phone/PhoneTabs.vue
src/web/src/phone/PhoneTicks.vue
src/web/src/phone/PhoneTurn.vue
src/web/src/phone/PhoneViewer.vue
src/web/src/phone/PhoneWaiting.vue
src/web/src/phone/agents.js
src/web/src/phone/announce.js
src/web/src/phone/bubbles.js
src/web/src/phone/cache.js
src/web/src/phone/device.js
src/web/src/phone/doing.js
src/web/src/phone/drag.js
src/web/src/phone/edge.js
src/web/src/phone/fades.js
src/web/src/phone/haptic.js
src/web/src/phone/keyboard.js
src/web/src/phone/kinds.js
src/web/src/phone/looked.js
src/web/src/phone/main.js
src/web/src/phone/once.js
src/web/src/phone/outbox.js
src/web/src/phone/peeked.js
src/web/src/phone/phone.css
src/web/src/phone/plain.js
src/web/src/phone/planGo.js
src/web/src/phone/quoted.js
src/web/src/phone/readerChoices.js
src/web/src/phone/reveal.js
src/web/src/phone/stash.js
src/web/src/phone/todo.js
src/web/src/phone/trap.js
src/web/src/phone/under.js
src/web/src/phone/waiting.js
src/web/src/phone/wanted.js
src/web/src/resource/TunnelLogin.vue
src/web/src/resource/TunnelProblem.vue
