"""Basic smoke tests for the WolfPanel Agent v1 foundation.

Run with:  python -m pytest tests/   (or: python tests/test_basic.py)

These tests use a temporary WOLFPANEL_DEV_HOME so nothing touches real system
paths, and the mock API so no network is required.
"""

from __future__ import annotations

import os
import sys
import tempfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))


def _fresh_config(tmp: str):
    os.environ["WOLFPANEL_DEV_HOME"] = tmp
    os.environ["WOLFPANEL_API_MOCK"] = "1"
    import importlib

    import config as config_module

    importlib.reload(config_module)
    return config_module.load_config()


def test_config_paths_under_dev_home():
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        assert str(cfg.conf_file).startswith(tmp)
        assert cfg.secrets_dir.name == "secrets"
        assert cfg.heartbeat_interval == 60


def test_manifest_url_default_matches_installer_release_base():
    # config.py's default must stay aligned with install.sh's RELEASE_BASE
    # default (both derive the same manifest URL) -- this is a regression
    # test for a drift where install.sh pointed at .../agent/... but the
    # running agent's own default silently pointed at .../ (no /agent),
    # causing every self-update check to 404.
    os.environ.pop("WOLFPANEL_RELEASE_BASE", None)
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        assert cfg.release_base == "https://downloads.wolfpanel.net/agent"
        assert cfg.manifest_url == f"https://downloads.wolfpanel.net/agent/{cfg.release_channel}/latest.json"


def test_fingerprint_is_stable_and_opaque():
    from identity import generate_fingerprint

    fp1 = generate_fingerprint()
    fp2 = generate_fingerprint()
    assert fp1 == fp2
    assert fp1.startswith("fp_")


def test_register_stores_credentials_and_not_install_token():
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        from api_client import get_api_client
        from identity import ensure_dirs, load_agent_token, store_credentials

        ensure_dirs(cfg)
        resp = get_api_client(cfg).register({"hostname": "t", "fingerprint": "fp_x"})
        # v1 register response: {agent_token, server_id, status} (no agent_id/refresh_token).
        store_credentials(cfg, str(resp["server_id"]), resp["agent_token"])

        # Token is stored securely bound to machine fingerprint (encrypted).
        assert cfg.agent_token_file.read_text().strip().startswith("wp_enc:")
        assert load_agent_token(cfg) == "wp_agent_dev_token"
        # The install token must never be written anywhere under the tree.
        for path in Path(tmp).rglob("*"):
            if path.is_file():
                if path == cfg.agent_token_file:
                    continue
                assert "install_token" not in path.read_text(errors="ignore")


def test_quick_discovery_returns_expected_keys():
    from discovery import quick

    snapshot = quick.collect()
    for key in ("hostname", "os", "architecture", "cpu_count", "private_ips"):
        assert key in snapshot


def test_git_remote_sanitization():
    from discovery.git import sanitize_remote

    assert (
        sanitize_remote("https://user:ghp_secret@github.com/a/b.git")
        == "https://github.com/a/b.git"
    )
    assert sanitize_remote("git@github.com:a/b.git") == "git@github.com:a/b.git"


def test_project_classification(tmp_path):
    from discovery.projects import classify

    (tmp_path / "artisan").write_text("")
    (tmp_path / "composer.json").write_text("{}")
    assert classify(tmp_path)["type"] == "laravel"


def test_update_version_comparison():
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        from api_client import get_api_client
        from update import check

        info = check(cfg, get_api_client(cfg))
        # Mock manifest reports 0.1.1 vs current 0.1.0-dev -> update available.
        assert info is not None and info.update_available is True


def test_private_ips_excludes_public_addresses():
    from discovery import system

    assert system._is_private_ipv4("10.0.0.5") is True
    assert system._is_private_ipv4("192.168.1.10") is True
    assert system._is_private_ipv4("172.16.4.4") is True
    assert system._is_private_ipv4("127.0.0.1") is True
    assert system._is_private_ipv4("169.254.1.1") is True
    # Public addresses must never be reported as private.
    assert system._is_private_ipv4("8.8.8.8") is False
    assert system._is_private_ipv4("172.32.0.1") is False
    assert system._is_private_ipv4("not-an-ip") is False
    # The collector itself only ever returns private addresses.
    for ip in system.private_ips():
        assert system._is_private_ipv4(ip)


def test_runtime_state_not_written_to_agent_conf():
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        from identity import ensure_dirs, save_last_error, save_status

        ensure_dirs(cfg)
        save_status(cfg, "revoked")
        save_last_error(cfg, "line1\nline2")

        # Runtime values land in state.json, never in the EnvironmentFile.
        assert cfg.state_file.is_file()
        state_text = cfg.state_file.read_text()
        assert "revoked" in state_text
        # Multi-line errors are sanitized to a single line.
        assert "line1 line2" in state_text
        if cfg.conf_file.is_file():
            conf_text = cfg.conf_file.read_text()
            assert "WOLFPANEL_STATUS" not in conf_text
            assert "WOLFPANEL_LAST_ERROR" not in conf_text


def test_heartbeat_revoked_on_401():
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        from api_client import ApiClient, ApiError
        from heartbeat import send_once
        from identity import ensure_dirs

        ensure_dirs(cfg)

        class Mock401Client(ApiClient):
            def heartbeat(self, payload, token):
                raise ApiError("Unauthorized", status_code=401)

        client = Mock401Client()
        success = send_once(cfg, client)
        assert success is False
        assert cfg.status == "revoked"
        assert cfg.last_error == "Unauthorized"

        # Heartbeat should be skipped when status is revoked
        success2 = send_once(cfg, client)
        assert success2 is False


def test_heartbeat_payload_has_version_info():
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        from api_client import ApiClient
        from heartbeat import send_once
        from identity import ensure_dirs

        ensure_dirs(cfg)

        payloads = []
        fetch_manifest_calls = []

        class MockClient(ApiClient):
            def heartbeat(self, payload, token):
                payloads.append(payload)

            def fetch_manifest(self, url):
                fetch_manifest_calls.append(url)
                return {
                    "manifest_version": 1,
                    "channel": "stable",
                    "version": "0.1.2",
                    "file": "wolfpanel-agent-0.1.2.tar.gz",
                    "sha256": "0" * 64,
                    "signature": "",
                    "size_bytes": 0,
                    "published_at": "2026-07-04T00:00:00Z",
                }

        client = MockClient()
        cfg.heartbeat_interval = 10
        
        # Reset globals in heartbeat module to clear state
        import heartbeat
        heartbeat._last_fetch_time = 0.0
        heartbeat._cached_latest_version = None
        heartbeat._cached_update_available = False

        # First heartbeat should fetch manifest
        success = send_once(cfg, client)
        assert success is True
        assert len(payloads) == 1
        assert payloads[0]["agent_version"] == cfg.agent_version
        assert payloads[0]["latest_version"] == "0.1.2"
        assert payloads[0]["update_available"] is True
        assert len(fetch_manifest_calls) == 1

        # Second heartbeat immediately after should use cache
        success2 = send_once(cfg, client)
        assert success2 is True
        assert len(payloads) == 2
        assert payloads[1]["agent_version"] == cfg.agent_version
        assert payloads[1]["latest_version"] == "0.1.2"
        assert payloads[1]["update_available"] is True
        # Fetch manifest should NOT be called again
        assert len(fetch_manifest_calls) == 1


def test_agent_self_update_command():
    import tempfile
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        from logger import setup_logging
        import logging
        setup_logging(cfg.agent_log, level=logging.DEBUG)
        
        from api_client import ApiClient
        from command_runner import process_commands
        from identity import ensure_dirs, store_credentials
        
        ensure_dirs(cfg)
        store_credentials(cfg, "server_123", "wp_agent_dev_token")
        
        # Reload config to pick up credentials
        cfg = _fresh_config(tmp)
        
        # Setup files for current version
        current_version_dir = cfg.opt_dir / "versions" / cfg.agent_version
        current_version_dir.mkdir(parents=True, exist_ok=True)
        (current_version_dir / "src").mkdir(parents=True, exist_ok=True)
        (current_version_dir / "src" / "main.py").write_text("print('running')", encoding="utf-8")
        (current_version_dir / "VERSION").write_text(cfg.agent_version, encoding="utf-8")
        
        current_link = cfg.opt_dir / "current"
        if current_link.exists() or current_link.is_symlink():
            if current_link.is_symlink():
                current_link.unlink()
            else:
                import shutil
                shutil.rmtree(current_link)
        
        try:
            os.symlink(current_version_dir, current_link)
        except OSError:
            import shutil
            shutil.copytree(current_version_dir, current_link)
        
        class MockClient(ApiClient):
            def get_pending_commands(self, token):
                return [{
                    "id": 1,
                    "command_type": "agent.update",
                    "payload": {}
                }]
                
            def fetch_manifest(self, url):
                return {
                    "manifest_version": 1,
                    "channel": "stable",
                    "version": "0.1.2",
                    "file": "wolfpanel-agent-0.1.2.tar.gz",
                    "sha256": "0" * 64,
                    "signature": "",
                    "size_bytes": 0,
                    "published_at": "2026-07-04T00:00:00Z",
                }
                
            def post_command_result(self, command_id, result_dict, token):
                assert command_id == 1
                assert result_dict["status"] == "succeeded"
                
        client = MockClient()
        from unittest.mock import patch
        with patch("subprocess.Popen") as mock_popen:
            process_commands(cfg, client)
        
        # Verify that current points to (or is updated to) the new version dir (0.1.2)
        if current_link.is_symlink():
            target = current_link.readlink()
            assert target.name == "0.1.2"
        else:
            version_file = current_link / "VERSION"
            assert version_file.is_file()
            version_val = version_file.read_text(encoding="utf-8").strip()
            if version_val != "0.1.2":
                print("--- AGENT LOG ---")
                if cfg.agent_log.is_file():
                    print(cfg.agent_log.read_text(encoding="utf-8"))
                else:
                    print("Agent log file does not exist!")
                print("--- END AGENT LOG ---")
            assert version_val == "0.1.2"
        
        # Clean up logging handlers to release file locks on Windows
        import logging
        logging.shutdown()
        root_logger = logging.getLogger("wolfpanel")
        for handler in list(root_logger.handlers):
            root_logger.removeHandler(handler)


def test_escape_mysql_string():
    from actions.database import escape_mysql_string
    assert escape_mysql_string("my_pass") == "my_pass"
    assert escape_mysql_string("my'pass") == "my''pass"
    assert escape_mysql_string("my\\pass") == "my\\\\pass"
    assert escape_mysql_string("my\\'pass") == "my\\\\''pass"


def test_sftp_directory_validation():
    from actions.sftp import _verify_safe_path
    import pytest
    with tempfile.TemporaryDirectory() as tmp:
        os.environ["WOLFPANEL_DEV_HOME"] = tmp
        
        # Valid path
        valid_path = Path(tmp) / "var" / "www" / "site1"
        valid_path.mkdir(parents=True, exist_ok=True)
        assert _verify_safe_path(str(valid_path)) == str(valid_path)
        
        # Invalid path outside root
        invalid_path = Path(tmp) / "etc"
        with pytest.raises(PermissionError):
            _verify_safe_path(str(invalid_path))
            
        # Path traversal check
        traversal_path = Path(tmp) / "var" / "www" / "site1" / ".." / ".." / "etc"
        with pytest.raises(PermissionError):
            _verify_safe_path(str(traversal_path))


def test_update_configurations():
    with tempfile.TemporaryDirectory() as tmp:
        cfg = _fresh_config(tmp)
        assert cfg.update_timeout == 60
        assert cfg.backup_retention == 5
        assert cfg.update_retention == 3
        assert cfg.download_timeout == 30


def test_backup_retention_limit():
    from actions.backup import handle_backup_create
    import actions.backup
    import shutil
    with tempfile.TemporaryDirectory() as tmp:
        # Save originals
        original_dev_home = actions.backup.DEV_HOME
        original_base = actions.backup.BACKUP_BASE_DIR
        
        # Override
        actions.backup.DEV_HOME = tmp
        actions.backup.BACKUP_BASE_DIR = os.path.realpath(os.path.join(tmp, "var", "backups", "wolfpanel"))
        
        os.environ["WOLFPANEL_DEV_HOME"] = tmp
        cfg = _fresh_config(tmp)
        
        # Setup directories
        docroot = Path(tmp) / "var" / "www" / "mysite"
        docroot.mkdir(parents=True, exist_ok=True)
        (docroot / "index.html").write_text("content")
        
        # Configure backup retention to 2
        os.environ["WOLFPANEL_BACKUP_RETENTION"] = "2"
        cfg = _fresh_config(tmp)
        
        try:
            # Create 3 backups
            import time
            res1 = handle_backup_create({"domain": "mysite.com", "docroot": str(docroot), "include_files": True, "include_db": False})
            assert res1["success"] is True
            
            time.sleep(1.05)
            res2 = handle_backup_create({"domain": "mysite.com", "docroot": str(docroot), "include_files": True, "include_db": False})
            assert res2["success"] is True
            
            time.sleep(1.05)
            res3 = handle_backup_create({"domain": "mysite.com", "docroot": str(docroot), "include_files": True, "include_db": False})
            assert res3["success"] is True
            
            # List backups
            backup_dir = Path(tmp) / "var" / "backups" / "wolfpanel" / "mysite.com"
            files = [p for p in backup_dir.iterdir() if p.name.endswith(".tar.gz")]
            
            # Only 2 backups should remain
            assert len(files) == 2
            # Oldest backup should be deleted
            assert not os.path.exists(res1["backup_path"])
            assert os.path.exists(res2["backup_path"])
            assert os.path.exists(res3["backup_path"])
        finally:
            actions.backup.DEV_HOME = original_dev_home
            actions.backup.BACKUP_BASE_DIR = original_base


if __name__ == "__main__":
    import traceback

    funcs = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
    failures = 0
    for func in funcs:
        try:
            # Provide a tmp_path for the one test that needs it.
            if "tmp_path" in func.__code__.co_varnames:
                with tempfile.TemporaryDirectory() as tmp:
                    func(Path(tmp))
            else:
                func()
            print(f"PASS {func.__name__}")
        except Exception:  # noqa: BLE001
            failures += 1
            print(f"FAIL {func.__name__}")
            traceback.print_exc()
    sys.exit(1 if failures else 0)
