"""Tests for the plugin loader behavior.

Pins the manifest-first design and the strict-vs-isolate failure policy
described in the enforcing-plugin-contract skill.
"""

import json
from pathlib import Path

import pytest

from shopdb.plugins.loader import PluginLoader
from shopdb.plugins.registry import PluginRegistry
from shopdb.exceptions import (
    PluginNotFoundError,
    PluginContractError,
    PluginVersionError,
    PluginDependencyError,
    PluginError,
)


@pytest.fixture
def temp_plugins_dir(tmp_path):
    """An empty temporary plugins directory."""
    plugins = tmp_path / 'plugins'
    plugins.mkdir()
    return plugins


@pytest.fixture
def temp_registry(tmp_path):
    """An empty temporary registry."""
    return PluginRegistry(tmp_path / 'plugins.json')


@pytest.fixture
def loader(temp_plugins_dir, temp_registry):
    """A loader pointed at the temporary plugins dir."""
    return PluginLoader(temp_plugins_dir, temp_registry)


def write_plugin(plugins_dir, name, manifest=None, plugin_py=None):
    """Helper: write a plugin directory with manifest.json and plugin.py."""
    plugin_dir = plugins_dir / name
    plugin_dir.mkdir()

    if manifest is not None:
        (plugin_dir / 'manifest.json').write_text(json.dumps(manifest))

    if plugin_py is not None:
        (plugin_dir / 'plugin.py').write_text(plugin_py)

    return plugin_dir


VALID_PLUGIN_PY = '''
from shopdb.plugins.base import BasePlugin, PluginMeta


class FakePlugin(BasePlugin):
    @property
    def meta(self):
        return PluginMeta(
            name='fake',
            version='1.0.0',
            description='Fake test plugin',
        )

    def get_blueprint(self):
        return None

    def get_models(self):
        return []
'''


def test_load_manifest_raises_on_missing_manifest(loader, temp_plugins_dir):
    """A plugin without manifest.json raises PluginNotFoundError."""
    plugin_dir = temp_plugins_dir / 'noplugin'
    plugin_dir.mkdir()
    (plugin_dir / 'plugin.py').write_text('# empty')

    with pytest.raises(PluginNotFoundError, match='manifest.json'):
        loader.load_manifest('noplugin')


def test_load_manifest_raises_on_invalid_json(loader, temp_plugins_dir):
    """An unparseable manifest.json raises PluginContractError."""
    plugin_dir = temp_plugins_dir / 'bad'
    plugin_dir.mkdir()
    (plugin_dir / 'manifest.json').write_text('{not json}')

    with pytest.raises(PluginContractError, match='invalid manifest.json'):
        loader.load_manifest('bad')


def test_load_manifest_raises_on_missing_required_fields(loader, temp_plugins_dir):
    """Missing name/version/description raises PluginContractError."""
    write_plugin(temp_plugins_dir, 'incomplete', manifest={'name': 'incomplete'})

    with pytest.raises(PluginContractError, match='missing required field'):
        loader.load_manifest('incomplete')


def test_load_manifest_raises_on_name_mismatch(loader, temp_plugins_dir):
    """manifest.name must match the directory name."""
    write_plugin(
        temp_plugins_dir,
        'mydir',
        manifest={'name': 'different', 'version': '1.0.0', 'description': 'x'},
    )

    with pytest.raises(PluginContractError, match='does not match manifest name'):
        loader.load_manifest('mydir')


def test_check_contract_version_passes_when_in_range(loader, temp_plugins_dir):
    """A core_version range that covers the framework version passes."""
    write_plugin(
        temp_plugins_dir,
        'compat',
        manifest={
            'name': 'compat',
            'version': '1.0.0',
            'description': 'x',
            'core_version': '>=0.1.0,<1.0.0',
        },
    )
    loader.check_contract_version('compat', '0.1.0')


def test_check_contract_version_raises_when_out_of_range(loader, temp_plugins_dir):
    """A core_version range that excludes the framework version raises."""
    write_plugin(
        temp_plugins_dir,
        'incompat',
        manifest={
            'name': 'incompat',
            'version': '1.0.0',
            'description': 'x',
            'core_version': '>=2.0.0',
        },
    )

    with pytest.raises(PluginVersionError, match='requires core_version'):
        loader.check_contract_version('incompat', '0.1.0')


def test_check_contract_version_skipped_when_unspecified(loader, temp_plugins_dir):
    """A manifest without core_version does not raise."""
    write_plugin(
        temp_plugins_dir,
        'unspec',
        manifest={
            'name': 'unspec',
            'version': '1.0.0',
            'description': 'x',
        },
    )
    loader.check_contract_version('unspec', '0.1.0')


def test_load_plugin_class_raises_on_missing_plugin_py(loader, temp_plugins_dir):
    """A directory with no plugin.py raises PluginNotFoundError."""
    plugin_dir = temp_plugins_dir / 'nofile'
    plugin_dir.mkdir()

    with pytest.raises(PluginNotFoundError, match='plugin.py not found'):
        loader.load_plugin_class('nofile')


def test_load_plugin_class_raises_when_no_subclass_found(loader, temp_plugins_dir):
    """A plugin.py without a BasePlugin subclass raises PluginContractError."""
    write_plugin(
        temp_plugins_dir,
        'empty',
        manifest={'name': 'empty', 'version': '1.0.0', 'description': 'x'},
        plugin_py='# no plugin defined',
    )

    with pytest.raises(PluginContractError, match='no BasePlugin subclass'):
        loader.load_plugin_class('empty')


def test_load_plugin_class_raises_on_import_error(loader, temp_plugins_dir):
    """A plugin.py with a syntax/import error raises PluginContractError."""
    write_plugin(
        temp_plugins_dir,
        'broken',
        manifest={'name': 'broken', 'version': '1.0.0', 'description': 'x'},
        plugin_py='import nonexistent_module_xyz',
    )

    with pytest.raises(PluginContractError, match='import failed'):
        loader.load_plugin_class('broken')


def test_sort_by_dependencies_uses_manifest_not_instantiation(loader, temp_plugins_dir):
    """Topological sort reads manifest.json directly, never instantiates."""
    write_plugin(
        temp_plugins_dir,
        'a',
        manifest={
            'name': 'a',
            'version': '1.0.0',
            'description': 'x',
            'dependencies': ['b'],
        },
    )
    write_plugin(
        temp_plugins_dir,
        'b',
        manifest={'name': 'b', 'version': '1.0.0', 'description': 'x'},
    )

    sorted_names = loader._sort_by_dependencies(['a', 'b'])
    assert sorted_names.index('b') < sorted_names.index('a')


def test_load_plugin_strict_mode_reraises(loader, app, temp_plugins_dir):
    """In TESTING/DEBUG mode, load_plugin re-raises plugin errors."""
    write_plugin(
        temp_plugins_dir,
        'incompat',
        manifest={
            'name': 'incompat',
            'version': '1.0.0',
            'description': 'x',
            'core_version': '>=99.0.0',
        },
        plugin_py=VALID_PLUGIN_PY,
    )

    assert app.config.get('TESTING') is True
    with pytest.raises(PluginVersionError):
        loader.load_plugin('incompat', app, None)


def test_load_plugin_production_mode_isolates(loader, temp_plugins_dir):
    """In production-like config, load_plugin returns None on failure."""
    from flask import Flask

    fake_app = Flask(__name__)
    fake_app.config['TESTING'] = False
    fake_app.config['DEBUG'] = False

    write_plugin(
        temp_plugins_dir,
        'incompat',
        manifest={
            'name': 'incompat',
            'version': '1.0.0',
            'description': 'x',
            'core_version': '>=99.0.0',
        },
        plugin_py=VALID_PLUGIN_PY,
    )

    result = loader.load_plugin('incompat', fake_app, None)
    assert result is None
