"""Tests for metafixer/license.py against an in-memory fake of the PHP
license server (same protocol, same token format, real Ed25519 signing).
Run:  python3 tests/test_license.py       (needs `cryptography` ONLY for tests)
"""
import base64, json, os, sys, tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization as S
from metafixer import license as L


def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()


class Clock:
    def __init__(self): self.t = 1_800_000_000.0; self.m = 1000.0
    def now(self): return self.t
    def mono(self): return self.m
    def adv(self, s): self.t += s; self.m += s


class FakeServer:
    """Mirror of license-server/api.php."""
    def __init__(self, clock, priv):
        self.c, self.priv = clock, priv
        self.trials, self.lics, self.devs = {}, {}, {}
        self.calls = 0; self.down = False; self.trial_seconds = 1800
    def add(self, key, plan, max_devices=1):
        self.lics[key] = dict(plan=plan, max=max_devices, act=None, exp=None, revoked=False)
    def sign(self, payload):
        raw = json.dumps(payload).encode()
        return b64u(raw) + "." + b64u(self.priv.sign(raw))
    def post(self, url, body):
        self.calls += 1
        if self.down: raise L.NetworkError("down")
        now = int(self.c.now()); did = body["device_id"]; a = body["action"]
        if a in ("trial_start", "trial_tick"):
            t = self.trials.setdefault(did, dict(used=0, last=now))
            if a == "trial_tick":
                d = max(0, min(120, body["seconds"]))
                allowed = max(0, now - t["last"] + 5)
                t["used"] = min(self.trial_seconds, t["used"] + min(d, allowed))
            t["last"] = now
            return {"ok": True, "token": self.sign(dict(v=1, typ="trial", did=did, plan="trial", exp=0, iat=now, chk=now+300, tl=max(0, self.trial_seconds - t["used"])))}
        key = body.get("key", "")
        lic = self.lics.get(key)
        if a == "deactivate":
            self.devs.pop((key, did), None); return {"ok": True}
        if not lic: return {"ok": False, "error": "invalid_key", "message": "not found"}
        if lic["revoked"]: return {"ok": False, "error": "revoked", "message": "disabled"}
        if lic["exp"] and now >= lic["exp"]: return {"ok": False, "error": "expired", "message": "expired"}
        if a == "activate":
            n = len([1 for (k, d) in self.devs if k == key])
            if (key, did) not in self.devs and n >= lic["max"]:
                return {"ok": False, "error": "device_limit", "message": "limit"}
            self.devs[(key, did)] = 1
            if lic["act"] is None:
                lic["act"] = now; days = {"month": 30, "year": 365, "lifetime": 0}[lic["plan"]]
                lic["exp"] = now + days * 86400 if days else None
        elif (key, did) not in self.devs:
            return {"ok": False, "error": "not_activated", "message": "no"}
        return {"ok": True, "token": self.sign(dict(v=1, typ="license", did=did, plan=lic["plan"], exp=lic["exp"] or 0, iat=now, chk=now + 72*3600))}


def make(tmp, srv, clk, did="a" * 32, **kw):
    pub = srv.priv.public_key().public_bytes(S.Encoding.Raw, S.PublicFormat.Raw)
    return L.LicenseManager(store_path=Path(tmp) / "license.json", api_url="https://x/api.php",
        public_key=pub, now_fn=clk.now, mono_fn=clk.mono, post_fn=srv.post,
        device_id=did, configured=True, dev_mode=False, **kw)


def main():
    n = 0
    def check(cond, msg):
        nonlocal n; n += 1
        assert cond, "FAILED: " + msg
    priv = Ed25519PrivateKey.generate()
    with tempfile.TemporaryDirectory() as tmp:
        clk = Clock(); srv = FakeServer(clk, priv); m = make(tmp, srv, clk)
        # --- before any contact
        check(m.state()["kind"] == "locked", "no token -> locked")
        # --- trial
        ok, _ = m.refresh(); st = m.state()
        check(ok and st["kind"] == "trial" and st["trial_left"] == 1800, "trial starts with 30 min")
        for _ in range(10):
            clk.adv(60); m.heartbeat()
        st = m.state()
        check(st["kind"] == "trial" and 1190 <= st["trial_left"] <= 1200, "10 min used -> ~20 left, got %s" % st["trial_left"])
        # --- reinstall: same store + same device id keeps the used time
        m2 = make(tmp, srv, clk); m2.refresh()
        check(1190 <= m2.state()["trial_left"] <= 1200, "restart keeps trial usage (server counted)")
        # --- fresh data folder, same PC: server still remembers
        with tempfile.TemporaryDirectory() as tmp2:
            m3 = make(tmp2, srv, clk); m3.refresh()
            check(m3.state()["trial_left"] <= 1200, "re-install does not reset trial")
        # --- client cannot hide time by skipping heartbeats
        clk.adv(200)
        check(m.state()["kind"] == "locked" and m.state()["reason"] == "offline", "no heartbeats for 3+ min -> locked offline")
        m.heartbeat()
        # --- trial runs out
        for _ in range(25):
            clk.adv(60); m.heartbeat()
        check(m.state()["reason"] == "trial_ended", "trial ends -> locked trial_ended")
        # --- wrong key does not disturb state
        srv.add("MFXR-AAAA-BBBB-CCCC-DDDD", "month"); srv.add("MFXR-LIFE-LIFE-LIFE-LIFE", "lifetime")
        ok, msg = m.activate("MFXR-NOPE-NOPE-NOPE-NOPE")
        check(not ok and m.state()["reason"] == "trial_ended", "wrong key: refused, still trial_ended")
        # --- activate month plan
        ok, _ = m.activate(" mfxr-aaaa-bbbb-cccc-dddd ")
        st = m.state()
        check(ok and st["kind"] == "licensed" and st["plan"] == "month", "month key activates")
        check(abs(st["expires_at"] - (clk.now() + 30 * 86400)) < 5, "expires in 30 days")
        # --- second PC blocked (1 device)
        m_b = make(tempfile.mkdtemp(), srv, clk, did="b" * 32)
        ok, msg = m_b.activate("MFXR-AAAA-BBBB-CCCC-DDDD")
        check(not ok and "limit" in msg, "second PC blocked by device limit")
        # --- offline for 2 days still works, 4 days does not
        srv.down = True
        clk.adv(2 * 86400); check(m.state()["kind"] == "licensed", "2 days offline ok")
        clk.adv(2 * 86400); check(m.state()["reason"] == "offline", "4 days offline -> locked")
        srv.down = False; ok, _ = m.refresh()
        check(ok and m.state()["kind"] == "licensed", "back online -> licensed again")
        # --- plan expiry
        clk.adv(27 * 86400); m.refresh()
        check(m.state()["reason"] == "expired", "after 30 days -> expired, got %s" % m.state())
        # --- renew with a lifetime key
        ok, _ = m.activate("MFXR-LIFE-LIFE-LIFE-LIFE")
        st = m.state()
        check(ok and st["plan"] == "lifetime" and st["expires_at"] == 0 and st["kind"] == "licensed", "lifetime key activates")
        clk.adv(5 * 365 * 86400); srv.lics["MFXR-LIFE-LIFE-LIFE-LIFE"]  # lifetime never expires
        m.refresh(); check(m.state()["kind"] == "licensed", "lifetime still valid after 5 years")
        # --- revoked key
        srv.lics["MFXR-LIFE-LIFE-LIFE-LIFE"]["revoked"] = True; m.refresh()
        check(m.state()["reason"] == "revoked", "revoked key locks the app")
        # --- tampered license.json
        srv.lics["MFXR-LIFE-LIFE-LIFE-LIFE"]["revoked"] = False; m.refresh()
        path = Path(tmp) / "license.json"; d = json.loads(path.read_text())
        body, sig = d["token"].split("."); raw = json.loads(base64.urlsafe_b64decode(body + "=="))
        raw["exp"] = 9_999_999_999; raw["plan"] = "lifetime"
        d["token"] = b64u(json.dumps(raw).encode()) + "." + sig; path.write_text(json.dumps(d))
        srv.down = True; m4 = make(tmp, srv, clk)
        check(m4.state()["kind"] == "locked", "edited token (bad signature) is rejected")
        # --- token from another PC
        srv.down = False
        other = make(tempfile.mkdtemp(), srv, clk, did="c" * 32); other.refresh()
        d["token"] = json.loads(Path(other.store_path).read_text())["token"]; path.write_text(json.dumps(d))
        srv.down = True; m5 = make(tmp, srv, clk)
        check(m5.state()["kind"] == "locked", "token copied from another PC is rejected")
        # --- clock rollback
        srv.down = False; m.refresh(); m._touch()
        clk.t -= 3 * 86400
        check(m.state()["reason"] == "clock", "clock set back -> locked")
        # --- not configured / dev mode
        pub = priv.public_key().public_bytes(S.Encoding.Raw, S.PublicFormat.Raw)
        mc = L.LicenseManager(store_path=Path(tmp) / "x.json", api_url="https://YOUR-DOMAIN.com/a.php", public_key=pub, device_id="d" * 32, dev_mode=False)
        check(mc.state()["reason"] == "not_configured", "unconfigured build fails closed")
        md = L.LicenseManager(store_path=Path(tmp) / "y.json", api_url="https://YOUR-DOMAIN.com/a.php", public_key=pub, device_id="d" * 32, dev_mode=True)
        check(md.state()["kind"] == "licensed", "dev mode allowed explicitly")
        # --- real default config matches the generated server public key
        #     (only when the server folder sits next to the app folder)
        from metafixer import license_config
        keys = Path(__file__).resolve().parents[2] / "keys.txt"
        phpf = Path(__file__).resolve().parents[2] / "license-server" / "config.php"
        if keys.exists() and phpf.exists():
            sec = open(keys).readline().split("=", 1)[1].strip()
            sk = base64.b64decode(sec); pk = base64.b64decode(license_config.PUBLIC_KEY_B64)
            check(sk[32:] == pk, "client public key == server secret key's public half")
            phpcfg = phpf.read_text()
            check(sec in phpcfg and license_config.PUBLIC_KEY_B64 in phpcfg, "config.php holds the same keys")
    print("license tests passed:", n, "checks")


if __name__ == "__main__":
    main()
