"""Test end-to-end SEBELUM upload ke hosting.

Menjalankan api.php sungguhan (php -S + SQLite sementara) lalu mensimulasikan
Agent dan Central. Jalankan:   python tests/test_system.py
Butuh: php (CLI) dan Python 3.10+. Tidak butuh MySQL / Windows / tkinter.
"""
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
import urllib.request

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "client"))

import agent as agent_mod  # noqa: E402
import netblock  # noqa: E402
from common import Api, ApiError  # noqa: E402

AGENT_KEY = "test-agent-key-1234567890"
ADMIN_KEY = "test-admin-key-1234567890"


def free_port():
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


class FakeSysinfo:
    """Pengganti sysinfo supaya test tidak bergantung pada Windows."""
    def __init__(self):
        self.window = ("Dokumen - Word", "WINWORD.EXE")

    def get_active_window(self):
        return self.window

    def list_windows(self):
        return [{"title": self.window[0], "process": self.window[1]}, {"title": "Chrome", "process": "chrome.exe"}]

    def list_processes(self):
        return [{"name": "chrome.exe", "count": 8}, {"name": "WINWORD.EXE", "count": 1}]

    def hostname(self):
        return "PC-TEST"

    def os_name(self):
        return "Windows-11-test"


class ServerCase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.tmp = tempfile.mkdtemp()
        cls.port = free_port()
        env = dict(os.environ, PCMON_DRIVER="sqlite", PCMON_SQLITE=os.path.join(cls.tmp, "t.sqlite"),
                   PCMON_AGENT_KEY=AGENT_KEY, PCMON_ADMIN_KEY=ADMIN_KEY, PCMON_ONLINE_TIMEOUT="2")
        cls.proc = subprocess.Popen(["php", "-S", "127.0.0.1:%d" % cls.port, "-t", os.path.join(ROOT, "server")],
                                    env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        cls.url = "http://127.0.0.1:%d/api.php" % cls.port
        for _ in range(50):
            try:
                urllib.request.urlopen(cls.url + "?action=ping", timeout=1).read()
                break
            except Exception:
                time.sleep(0.1)
        else:
            raise RuntimeError("php -S tidak mau start")
        cls.admin = Api(cls.url, ADMIN_KEY)
        cls.agent_api = Api(cls.url, AGENT_KEY)

    @classmethod
    def tearDownClass(cls):
        cls.proc.terminate()
        cls.proc.wait(5)
        shutil.rmtree(cls.tmp, ignore_errors=True)

    def sync(self, device_id, **extra):
        body = {"device_id": device_id, "name": "PC " + device_id, "os": "Win",
                "active_window": "Notepad", "active_process": "notepad.exe",
                "windows": [{"title": "Notepad", "process": "notepad.exe"}],
                "processes": [{"name": "notepad.exe", "count": 1}]}
        body.update(extra)
        return self.agent_api.call("agent_sync", body)


class TestApi(ServerCase):
    def test_01_ping_and_auth(self):
        self.assertTrue(Api(self.url, "").call("ping")["db"])
        with self.assertRaises(ApiError) as c:
            Api(self.url, "salah").call("devices")
        self.assertEqual(c.exception.status, 401)
        with self.assertRaises(ApiError) as c:
            self.agent_api.call("devices")          # kunci agent tidak boleh mengontrol
        self.assertEqual(c.exception.status, 403)
        with self.assertRaises(ApiError) as c:
            self.admin.call("agent_sync", {"device_id": "x"})   # kunci admin bukan kunci agent
        self.assertEqual(c.exception.status, 403)

    def test_02_agent_registers_and_is_listed(self):
        res = self.sync("pc01")
        self.assertEqual(res["state"], {"locked": False, "internet_blocked": False, "max_tabs": 0,
                                        "blocked_sites": [], "lock_message": ""})
        devs = {d["id"]: d for d in self.admin.call("devices")["devices"]}
        self.assertTrue(devs["pc01"]["online"])
        self.assertEqual(devs["pc01"]["active_window"], "Notepad")
        self.assertEqual(devs["pc01"]["active_process"], "notepad.exe")

    def test_03_lock_and_unlock_from_central(self):
        self.sync("pc02")
        self.admin.call("set", {"device_id": "pc02", "locked": True, "lock_message": "Jam istirahat"})
        st = self.sync("pc02")["state"]
        self.assertTrue(st["locked"])
        self.assertEqual(st["lock_message"], "Jam istirahat")
        self.admin.call("set", {"device_id": "pc02", "locked": False})
        self.assertFalse(self.sync("pc02")["state"]["locked"])

    def test_04_internet_tabs_and_sites(self):
        self.sync("pc03")
        self.admin.call("set", {"device_id": "pc03", "internet_blocked": True, "max_tabs": 3,
                                "blocked_sites": "https://www.YouTube.com/watch?v=1\nfacebook.com\nyoutube.com\n<script>"})
        st = self.sync("pc03")["state"]
        self.assertTrue(st["internet_blocked"])
        self.assertEqual(st["max_tabs"], 3)
        self.assertEqual(st["blocked_sites"], ["youtube.com", "facebook.com"])  # dinormalkan, tanpa duplikat/sampah
        self.admin.call("set", {"device_id": "pc03", "max_tabs": 99999})
        self.assertEqual(self.sync("pc03")["state"]["max_tabs"], 500)           # dibatasi

    def test_05_apply_to_all(self):
        self.sync("pc04")
        self.sync("pc05")
        total = len(self.admin.call("devices")["devices"])
        res = self.admin.call("set", {"device_id": "*", "locked": True})
        self.assertEqual(res["affected"], total)
        self.assertTrue(self.sync("pc04")["state"]["locked"])
        self.assertTrue(self.sync("pc05")["state"]["locked"])
        self.admin.call("set", {"device_id": "*", "locked": False})

    def test_06_tabs_windows_processes_visible_to_central(self):
        tabs = [{"title": "Google", "url": "https://google.com"}, {"title": "YT", "url": "https://youtube.com"}]
        self.sync("pc06", tabs=tabs)
        d = self.admin.call("device", id="pc06")
        self.assertEqual(d["tab_count"], 2)
        self.assertEqual(d["tabs"][1]["url"], "https://youtube.com")
        self.assertEqual(d["windows"][0]["title"], "Notepad")
        self.assertEqual(d["processes"][0]["name"], "notepad.exe")
        self.sync("pc06")  # tanpa field tabs -> jumlah tab tidak tertimpa
        self.assertEqual(self.admin.call("device", id="pc06")["tab_count"], 2)

    def test_07_history_only_logs_changes(self):
        self.sync("pc07", active_window="A", active_process="a.exe")
        self.sync("pc07", active_window="A", active_process="a.exe")
        self.sync("pc07", active_window="B", active_process="b.exe")
        hist = self.admin.call("device", id="pc07")["history"]
        self.assertEqual([h["active_window"] for h in hist], ["B", "A"])

    def test_08_offline_detection_and_delete(self):
        self.sync("pc08")
        time.sleep(3)  # online_timeout test = 2 detik
        devs = {d["id"]: d for d in self.admin.call("devices")["devices"]}
        self.assertFalse(devs["pc08"]["online"])
        self.admin.call("delete_device", {"device_id": "pc08"})
        self.assertNotIn("pc08", {d["id"] for d in self.admin.call("devices")["devices"]})

    def test_09_bad_input(self):
        with self.assertRaises(ApiError):
            self.agent_api.call("agent_sync", {"name": "tanpa id"})
        with self.assertRaises(ApiError) as c:
            self.admin.call("set", {"device_id": "tidak-ada", "locked": True})
        self.assertEqual(c.exception.status, 404)
        with self.assertRaises(ApiError):
            self.admin.call("set", {"device_id": "*"})  # tidak ada perubahan


class TestAgentEndToEnd(ServerCase):
    """Agent sungguhan (AgentCore) <-> server sungguhan <-> Central."""

    def make_agent(self, name="agent"):
        blocker = netblock.NetBlocker(self.url, dry_run=True)
        state_path = os.path.join(self.tmp, name + ".json")
        core = agent_mod.AgentCore({"device_name": name}, Api(self.url, AGENT_KEY), blocker,
                                   si=FakeSysinfo(), state_path=state_path)
        return core, blocker, state_path

    def test_agent_follows_central_commands(self):
        core, blocker, _ = self.make_agent("e2e")
        self.assertTrue(core.sync_once())
        self.assertFalse(core.snapshot_state()["locked"])

        self.admin.call("set", {"device_id": core.device_id, "locked": True, "internet_blocked": True, "max_tabs": 2})
        core.sync_once()
        st = core.snapshot_state()
        self.assertTrue(st["locked"])
        self.assertEqual(st["max_tabs"], 2)
        self.assertTrue(blocker.applied)
        self.assertTrue(any("blockoutbound" in c for c in blocker.history))

        self.admin.call("set", {"device_id": core.device_id, "locked": False, "internet_blocked": False})
        core.sync_once()
        self.assertFalse(core.snapshot_state()["locked"])
        self.assertFalse(blocker.applied)
        self.assertIn("netsh advfirewall set allprofiles firewallpolicy blockinbound,allowoutbound", blocker.history)

    def test_state_survives_restart_and_server_outage(self):
        core, blocker, state_path = self.make_agent("persist")
        core.sync_once()
        self.admin.call("set", {"device_id": core.device_id, "locked": True, "internet_blocked": True})
        core.sync_once()
        # "restart": agent baru dengan server tidak terjangkau -> tetap terkunci dari cache
        dead = Api("http://127.0.0.1:1/api.php", AGENT_KEY, timeout=1)
        blocker2 = netblock.NetBlocker(self.url, dry_run=True)
        core2 = agent_mod.AgentCore({}, dead, blocker2, si=FakeSysinfo(), state_path=state_path)
        self.assertEqual(core2.device_id, core.device_id)
        self.assertFalse(core2.sync_once())
        self.assertTrue(core2.snapshot_state()["locked"])
        self.assertTrue(blocker2.applied)
        self.admin.call("set", {"device_id": core.device_id, "locked": False, "internet_blocked": False})

    def test_extension_tabs_via_local_server(self):
        core, _, _ = self.make_agent("ext")
        port = free_port()
        server = agent_mod.start_local_server(core, port)
        try:
            base = "http://127.0.0.1:%d" % port
            core.sync_once()
            self.admin.call("set", {"device_id": core.device_id, "max_tabs": 4, "blocked_sites": ["tiktok.com"]})
            core.sync_once()

            # ekstensi membaca aturan
            state = json.loads(urllib.request.urlopen(base + "/state", timeout=3).read())["state"]
            self.assertEqual(state["max_tabs"], 4)
            self.assertEqual(state["blocked_sites"], ["tiktok.com"])

            # ekstensi mengirim tab
            def post(origin):
                req = urllib.request.Request(base + "/tabs", method="POST", headers={"Content-Type": "application/json", "Origin": origin},
                                             data=json.dumps({"tabs": [{"title": "Google", "url": "https://google.com"},
                                                                       {"title": "Mail", "url": "https://mail.google.com"}]}).encode())
                return urllib.request.urlopen(req, timeout=3)
            self.assertEqual(post("chrome-extension://abcdef").status, 200)
            with self.assertRaises(urllib.error.HTTPError) as c:
                post("https://jahat.example")           # halaman web biasa ditolak
            self.assertEqual(c.exception.code, 403)

            core.sync_once()
            d = self.admin.call("device", id=core.device_id)
            self.assertEqual(d["tab_count"], 2)
            self.assertEqual(d["tabs"][0]["url"], "https://google.com")
        finally:
            server.shutdown()
            server.server_close()

    def test_stale_tabs_reported_as_zero(self):
        core, _, _ = self.make_agent("stale")
        core.set_tabs([{"title": "x", "url": "https://x.com"}])
        self.assertEqual(len(core.current_tabs()), 1)
        core.tabs_ts -= agent_mod.TABS_STALE_SECONDS + 1   # Chrome sudah lama tidak melapor
        self.assertEqual(core.current_tabs(), [])


class TestNetBlock(unittest.TestCase):
    def test_block_commands(self):
        cmds = netblock.build_block_commands(["1.2.3.4", "5.6.7.8"], agent_exe=r"C:\Program Files\PCMon\PCMonAgent.exe")
        joined = "\n".join(cmds)
        self.assertIn("remoteip=1.2.3.4,5.6.7.8", joined)
        self.assertIn("remoteport=53", joined)            # DNS tetap jalan agar server bisa di-resolve
        self.assertIn('program="C:\\Program Files\\PCMon\\PCMonAgent.exe"', joined)
        self.assertTrue(cmds[-1].endswith("blockinbound,blockoutbound"))
        # aturan izin harus dibuat SEBELUM kebijakan block aktif, supaya koneksi ke server tidak putus
        self.assertLess(joined.index("ALLOW_SERVER"), joined.index("blockoutbound"))

    def test_apply_is_idempotent(self):
        b = netblock.NetBlocker("https://127.0.0.1/pcmon/api.php", dry_run=True)
        self.assertTrue(b.apply(True))
        n = len(b.history)
        self.assertFalse(b.apply(True))        # tidak menjalankan netsh berulang tiap 3 detik
        self.assertEqual(len(b.history), n)
        self.assertTrue(b.apply(False))
        self.assertFalse(b.apply(False))
        self.assertTrue(b.history[-len(netblock.build_unblock_commands()):] == netblock.build_unblock_commands())


if __name__ == "__main__":
    unittest.main(verbosity=2)
