"""Remote access to the MCP bridge: through a domain name (e.g. frp → this machine), with a
password, instead of only from a browser on this machine.

    "remote": {"hosts": ["mcp.example.com"], "session_hours": 12}

and the password in the environment variable WEBTT_MCP_PASSWORD (16+ characters). A request
whose Host is one of `hosts` must carry a session cookie from POST /api/mcp/login. The
cookie is HttpOnly, SameSite=Strict and — unless "insecure_http": true — Secure, so the
domain must be served over HTTPS. Sessions live in memory: restarting serve.py ends them.

Behind a proxy every request arrives from 127.0.0.1, and a proxy that rewrites Host to
localhost (e.g. frp's hostHeaderRewrite) would make internet requests look local. So once
"remote" is configured, localhost is not trusted either: it logs in like a remote host and
reaches the same servers. "trust_localhost": true restores the no-login local flow — only for
setups where nothing in front of serve.py can rewrite Host.

Every request arrives from 127.0.0.1, so failed logins can't be limited per
client: after FREE_ATTEMPTS failures in a row, login is locked for 60 s, doubling with each
further failure up to MAX_LOCK. While locked even the right password is refused, so the lock
gives nothing away. Local access (a browser on this machine) is unaffected by all of this.
"""
import hashlib
import hmac
import re
import secrets
import threading
import time
from dataclasses import dataclass

SESSION_COOKIE = 'mcp_session'
PASSWORD_ENV = 'WEBTT_MCP_PASSWORD'
MIN_PASSWORD = 16
FREE_ATTEMPTS = 5          # failed logins before the first lock
FIRST_LOCK = 60            # seconds
MAX_LOCK = 3600
DEFAULT_SESSION_HOURS = 12
KEYS = {'hosts', 'session_hours', 'insecure_http', 'trust_localhost'}
HOST_RE = re.compile(r'^[A-Za-z0-9.-]+(:[0-9]{1,5})?$')


def is_local_name(host):
    """localhost / 127.x / [::1], with or without a port."""
    name = host.lower().rsplit(':', 1)[0] if not host.startswith('[') else host.lower().split(']')[0] + ']'
    return name in ('localhost', '[::1]') or name.startswith('127.')


@dataclass(frozen=True)
class LoginResult:
    token: str | None = None
    retry_after: int | None = None  # set while login is locked


def _digest(text):
    return hashlib.sha256(text.encode()).digest()


class RemoteAccess:
    def __init__(self, hosts, password, session_hours, insecure_http, trust_localhost=False, clock=time.monotonic):
        self.hosts = {h.lower() for h in hosts}
        self.trust_localhost = trust_localhost
        self.password = _digest(password)
        self.session_seconds = int(session_hours * 3600)
        self.insecure_http = insecure_http
        self.clock = clock
        self.lock = threading.Lock()
        self.sessions = {}  # token → expiry (clock time)
        self.reset_limits()

    @classmethod
    def from_config(cls, cfg, env, clock=time.monotonic):
        """None without a "remote" section; raises ValueError for a bad one."""
        if cfg is None:
            return None
        if not isinstance(cfg, dict) or set(cfg) - KEYS:
            raise ValueError(f'"remote" takes only {", ".join(sorted(KEYS))}')
        hosts = cfg.get('hosts')
        if not (isinstance(hosts, list) and hosts and all(isinstance(h, str) and HOST_RE.match(h) for h in hosts)):
            raise ValueError('"remote.hosts" must be a non-empty list of host names (optionally with :port)')
        if any(is_local_name(h) for h in hosts):
            raise ValueError('"remote.hosts" lists a local name: localhost is handled by "trust_localhost"')
        hours = cfg.get('session_hours', DEFAULT_SESSION_HOURS)
        if not (isinstance(hours, (int, float)) and not isinstance(hours, bool) and 0 < hours <= 24 * 30):
            raise ValueError('"remote.session_hours" must be a number of hours between 0 and 720')
        insecure = cfg.get('insecure_http', False)
        trust = cfg.get('trust_localhost', False)
        if not (isinstance(insecure, bool) and isinstance(trust, bool)):
            raise ValueError('"remote.insecure_http" and "remote.trust_localhost" must be true or false')
        password = env.get(PASSWORD_ENV, '')
        if not password:
            raise ValueError(f'"remote" is configured: set the password in the environment variable {PASSWORD_ENV}')
        if len(password) < MIN_PASSWORD:
            raise ValueError(f'{PASSWORD_ENV} must be at least {MIN_PASSWORD} characters')
        return cls(hosts, password, hours, insecure, trust, clock)

    # ── requests ──

    def is_remote_host(self, host):
        return host.lower() in self.hosts

    def origin_ok(self, host, origin):
        # localhost is a secure context over plain http, so its Secure cookie still works
        host = host.lower()
        schemes = ('https', 'http') if self.insecure_http or is_local_name(host) else ('https',)
        return origin in {f'{s}://{host}' for s in schemes}

    # ── login and sessions ──

    def reset_limits(self):
        with self.lock:
            self.failures, self.locked_until, self.lock_seconds = 0, 0.0, FIRST_LOCK // 2

    def login(self, password):
        now = self.clock()
        with self.lock:
            if now < self.locked_until:
                return LoginResult(retry_after=int(self.locked_until - now + 0.999))
            if hmac.compare_digest(_digest(password), self.password):
                self.failures, self.lock_seconds = 0, FIRST_LOCK // 2
                token = secrets.token_urlsafe(32)
                self.sessions = {t: e for t, e in self.sessions.items() if e > now}  # drop expired
                self.sessions[token] = now + self.session_seconds
                return LoginResult(token=token)
            self.failures += 1
            if self.failures >= FREE_ATTEMPTS:
                self.lock_seconds = min(self.lock_seconds * 2, MAX_LOCK)
                self.locked_until = now + self.lock_seconds
            return LoginResult()

    def valid(self, token):
        if not token:
            return False
        with self.lock:
            expiry = self.sessions.get(token)
            if expiry is None:
                return False
            if expiry <= self.clock():
                del self.sessions[token]
                return False
            return True

    def expires_in(self, token):
        with self.lock:
            return max(0, int(self.sessions.get(token, 0) - self.clock()))

    def logout(self, token):
        with self.lock:
            self.sessions.pop(token, None)

    def cookie_header(self, token):
        secure = '' if self.insecure_http else '; Secure'
        return f'{SESSION_COOKIE}={token}; Path=/api; HttpOnly; SameSite=Strict; Max-Age={self.session_seconds}{secure}'

    def clear_cookie_header(self):
        secure = '' if self.insecure_http else '; Secure'
        return f'{SESSION_COOKIE}=; Path=/api; HttpOnly; SameSite=Strict; Max-Age=0{secure}'

    def startup_lines(self):
        lines = [f'  远程访问：{"、".join(sorted(self.hosts))}（需要密码，会话 {self.session_seconds // 3600} 小时）']
        lines.append('  本机 localhost：' + ('不用登录（trust_localhost：确保前面的代理不会把 Host 改写成 localhost）' if self.trust_localhost
                                            else '同样需要登录，只能用远程可用的服务端（设 "trust_localhost": true 可免登录）'))
        if self.insecure_http:
            lines.append('  ⚠ insecure_http：密码和会话令牌经 http 明文传输，只在可信网络里这样用')
        return lines
