"""Tests for the license CLI + the per-CLI guard.

Two layers:

1. ``src/license_cli.py`` commands — ``activate``, ``renew``,
   ``status``, ``trial``, ``deactivate``. Invoked via Typer's testing
   helper so we get a clean ``CliRunner.invoke`` interface without
   spawning subprocesses for every test.

2. ``src/cli_license_guard.py`` — verify that every existing tool CLI
   refuses to run when no license is present, and that ``--help``
   always works regardless of license state.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

import pytest
from typer.testing import CliRunner

from src.license_cli import app as license_app


PROJECT_ROOT = Path(__file__).resolve().parent.parent


# ---------------------------------------------------------------------------
# license_cli commands
# ---------------------------------------------------------------------------

class TestLicenseCliStatus:
    def test_status_without_activation_exits_nonzero(self, unactivated_license_manager):
        runner = CliRunner()
        result = runner.invoke(license_app, ["status"])
        assert result.exit_code == 1
        assert "not activated" in result.stdout.lower()

    def test_status_with_active_license(self, activated_license_manager):
        runner = CliRunner()
        result = runner.invoke(license_app, ["status"])
        assert result.exit_code == 0
        assert "active" in result.stdout.lower()
        assert "Test User" in result.stdout

    def test_status_json_emits_valid_json(self, activated_license_manager):
        runner = CliRunner()
        result = runner.invoke(license_app, ["status", "--json"])
        assert result.exit_code == 0
        data = json.loads(result.stdout)
        assert data["activated"] is True
        assert data["name"] == "Test User"
        assert data["tier"] == "core"
        assert isinstance(data["features"], list)
        assert data["days_remaining"] >= 0


class TestNoTrialSubcommand:
    """The ``trial`` subcommand was removed in v1.6 (no free trial —
    paid licenses only). Pin its absence so a future re-add has to be
    intentional."""

    def test_trial_subcommand_not_registered(self):
        runner = CliRunner()
        result = runner.invoke(license_app, ["trial", "--help"])
        assert result.exit_code != 0
        # Typer surfaces unknown commands with "No such command" or
        # similar — exact wording varies by version, so we just confirm
        # it's a usage error, not a successful execution.


class TestLicenseCliActivate:
    def _make_blob(self, name="Buyer", email="buyer@example.com", tier="core"):
        """Mint a blob via the same machinery scripts/generate_license.py uses."""
        from src.license import LicenseManager, Tier
        from src.license.crypto import encode_blob
        # Use a throwaway manager (separate path) so we don't trample
        # the one the test is exercising.
        from tempfile import mkstemp
        _, p = mkstemp(suffix=".json")
        mgr = LicenseManager()
        mgr._path = Path(p)
        lic = mgr._mint(name=name, email=email, tier=Tier(tier))
        Path(p).unlink(missing_ok=True)
        return encode_blob(lic.to_dict())

    def test_activate_round_trip(self, unactivated_license_manager):
        blob = self._make_blob()
        runner = CliRunner()
        result = runner.invoke(license_app, [
            "activate", blob,
            "--name", "Buyer", "--email", "buyer@example.com",
        ])
        assert result.exit_code == 0
        assert "Activated" in result.stdout
        # State is now active.
        from src.license import LicenseManager
        assert LicenseManager().is_valid()

    def test_activate_rejects_wrong_name(self, unactivated_license_manager):
        blob = self._make_blob()
        runner = CliRunner()
        result = runner.invoke(license_app, [
            "activate", blob,
            "--name", "Wrong Person", "--email", "buyer@example.com",
        ])
        assert result.exit_code == 2
        assert "do not match" in result.output.lower()


class TestLicenseCliRenew:
    def test_renew_extends_expiry(self, activated_license_manager):
        # Mint a longer-duration blob for the same buyer.
        from src.license import LicenseManager, Tier
        from src.license.crypto import encode_blob
        from tempfile import mkstemp
        _, p = mkstemp(suffix=".json")
        other = LicenseManager()
        other._path = Path(p)
        lic = other._mint(
            name="Test User", email="test@example.com",
            tier=Tier.CORE, years=2,
        )
        Path(p).unlink(missing_ok=True)
        blob = encode_blob(lic.to_dict())

        runner = CliRunner()
        result = runner.invoke(license_app, ["renew", blob])
        assert result.exit_code == 0
        assert "Renewed" in result.stdout


class TestLicenseCliDeactivate:
    def test_deactivate_with_yes(self, activated_license_manager):
        runner = CliRunner()
        result = runner.invoke(license_app, ["deactivate", "--yes"])
        assert result.exit_code == 0
        assert "Deactivated" in result.stdout
        from src.license import LicenseManager
        assert not LicenseManager().is_activated()


# ---------------------------------------------------------------------------
# Guard tests — every tool CLI refuses to run without a license
# ---------------------------------------------------------------------------

class TestCliLicenseGuard:
    """Run each tool CLI as a subprocess so we exercise the real
    ``main()`` path, including the guard call. We bypass the suite's
    DEV_MODE bypass by clearing the env var in the subprocess."""

    @pytest.fixture
    def clean_env(self, tmp_path):
        """Subprocess env: no DEV_MODE, license path in tmp_path."""
        env = dict(os.environ)
        env.pop("DATATOOLS_DEV_MODE", None)
        env["DATATOOLS_LICENSE_PATH"] = str(tmp_path / "license.json")
        return env

    def _run(self, env, *args, expect_success=False):
        proc = subprocess.run(
            [sys.executable, "-m", *args],
            cwd=PROJECT_ROOT,
            env=env,
            capture_output=True,
            text=True,
            timeout=60,
        )
        if expect_success:
            assert proc.returncode == 0, (
                f"Expected success, got {proc.returncode}\n"
                f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
            )
        return proc

    @pytest.mark.parametrize("module", [
        "src.cli",
        "src.cli_text_clean",
        "src.cli_format",
        "src.cli_missing",
        "src.cli_column_map",
        "src.cli_pipeline",
        "src.cli_analyze",
    ])
    def test_cli_blocked_without_license(self, clean_env, module):
        # Run with a dummy filename so we'd otherwise be running the
        # tool; the guard should fire BEFORE typer parses argv.
        proc = self._run(clean_env, module, "/nonexistent.csv")
        assert proc.returncode == 2
        assert "not activated" in proc.stderr.lower() or "license" in proc.stderr.lower()

    @pytest.mark.parametrize("module", [
        "src.cli",
        "src.cli_text_clean",
        "src.cli_format",
        "src.cli_missing",
        "src.cli_column_map",
        "src.cli_pipeline",
        "src.cli_analyze",
    ])
    def test_help_always_works(self, clean_env, module):
        # ``--help`` must bypass the guard so users can see usage
        # before they activate.
        proc = self._run(clean_env, module, "--help", expect_success=True)
        assert "usage" in (proc.stdout + proc.stderr).lower()

    def test_dev_mode_bypasses_guard(self, clean_env, tmp_path):
        # Set DEV_MODE; the guard should allow the CLI to run (and
        # then fail on the missing input file with a non-license
        # error — which is what we're asserting via stderr).
        env = dict(clean_env)
        env["DATATOOLS_DEV_MODE"] = "1"
        proc = self._run(env, "src.cli_analyze", "/nonexistent.csv")
        # We expect typer / our code to fail on the missing path,
        # NOT on the license. Look for evidence the license check
        # was bypassed.
        assert "not activated" not in proc.stderr.lower()
        # Either pandas / our io.py raises a FileNotFoundError-ish.
        combined = (proc.stdout + proc.stderr).lower()
        assert "no such file" in combined or "not found" in combined or proc.returncode != 0


class TestGuardBypassesHelp:
    """Sanity check: ``--help`` and friends must skip the guard so the
    Typer help screen renders even when no license is on disk."""

    def test_runs_under_help_flag_without_license(self, tmp_path, monkeypatch):
        # In-process check via the guard helper.
        monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
        monkeypatch.setenv("DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json"))
        from src.license.manager import reset_singleton_for_tests
        reset_singleton_for_tests()

        monkeypatch.setattr("sys.argv", ["progname", "--help"])
        from src.cli_license_guard import guard
        # No exception expected: --help bypasses.
        guard()

    def test_blocks_under_real_command_without_license(
        self, tmp_path, monkeypatch,
    ):
        monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
        monkeypatch.setenv("DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json"))
        from src.license.manager import reset_singleton_for_tests
        reset_singleton_for_tests()

        monkeypatch.setattr("sys.argv", ["progname", "input.csv", "--apply"])
        from src.cli_license_guard import guard
        with pytest.raises(SystemExit) as ei:
            guard()
        assert ei.value.code == 2
