"""Integration tests for the install → record → remove lifecycle (offline, fake installer).

Uses :class:`FakeInstaller` so no network or real primitive machinery is touched
(Constitution Principle II network-mocking, Principle IV offline-first).
"""
from __future__ import annotations

from pathlib import Path

import pytest

from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
from specify_cli.bundler.services.resolver import resolve_install_plan
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict


def _plan(manifest):
    return resolve_install_plan(
        manifest, speckit_version="0.11.2", active_integration="copilot"
    )


def test_install_records_and_invokes_primitives(tmp_path: Path):
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller()

    result = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)

    assert len(result.installed) == 4
    assert len(installer.install_calls) == 4
    records = load_records(tmp_path)
    assert len(records) == 1
    assert records[0].bundle_id == "demo-bundle"


def test_install_is_idempotent(tmp_path: Path):
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller()

    install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
    second = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)

    # Second install adds nothing and does not duplicate the record.
    assert second.installed == []
    assert len(second.skipped) == 4
    assert len(load_records(tmp_path)) == 1


def test_partial_failure_rolls_back_and_records_nothing(tmp_path: Path):
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller(fail_on="preset-a")

    with pytest.raises(BundlerError):
        install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)

    # ext-a was installed first, then rolled back; no record persisted.
    assert installer.installed == set()
    assert load_records(tmp_path) == []


def test_remove_is_non_collateral(tmp_path: Path):
    make_project(tmp_path)
    installer = FakeInstaller()

    # Bundle A provides a shared preset; Bundle B also provides it.
    data_a = valid_manifest_dict()
    data_a["bundle"]["id"] = "a"
    data_b = valid_manifest_dict()
    data_b["bundle"]["id"] = "b"
    data_b["provides"] = {"presets": [
        {"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
    ]}

    man_a = BundleManifest.from_dict(data_a)
    man_b = BundleManifest.from_dict(data_b)
    install_bundle(tmp_path, _plan(man_a), installer, manifest=man_a)
    install_bundle(tmp_path, _plan(man_b), installer, manifest=man_b)

    # Removing B must NOT uninstall preset-a (still needed by A).
    result = remove_bundle(tmp_path, "b", installer)
    assert ("presets", "preset-a") in {(c.kind, c.id) for c in result.skipped}
    assert installer.is_installed(tmp_path, man_a.presets[0]) is True

    remaining = {r.bundle_id for r in load_records(tmp_path)}
    assert remaining == {"a"}


def test_remove_unknown_bundle_errors(tmp_path: Path):
    make_project(tmp_path)
    with pytest.raises(BundlerError, match="not installed"):
        remove_bundle(tmp_path, "ghost", FakeInstaller())


def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller()
    install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)

    result = remove_bundle(tmp_path, "demo-bundle", installer)

    # Removal flows populate the dedicated ``uninstalled`` list; ``installed``
    # stays empty so the result type is never ambiguous for callers.
    assert result.installed == []
    assert len(result.uninstalled) == 4
    assert installer.installed == set()


def test_remove_counts_only_components_actually_removed(tmp_path: Path):
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller()
    install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)

    # Simulate one contributed component already gone from disk (e.g. removed
    # out of band). It must not be reported as uninstalled and remove() must
    # not be called for it.
    gone = manifest.components[0]
    installer.installed.discard((gone.kind, gone.id))

    result = remove_bundle(tmp_path, "demo-bundle", installer)

    assert len(result.uninstalled) == 3
    assert (gone.kind, gone.id) not in installer.remove_calls
    assert gone in result.skipped
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller()

    install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
    result = install_bundle(
        tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
    )

    # With refresh, already-installed components are re-applied, not skipped.
    assert result.skipped == []
    assert len(result.refreshed) == 4
    assert len(installer.refresh_calls) == 4
    assert result.changed is True


def test_refresh_falls_back_to_install_without_hook(tmp_path: Path):
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())

    class NoRefreshInstaller(FakeInstaller):
        refresh = None  # type: ignore[assignment]

    installer = NoRefreshInstaller()
    install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
    before = len(installer.install_calls)
    result = install_bundle(
        tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
    )

    # No refresh hook → re-install path keeps components current.
    assert len(result.refreshed) == 4
    assert len(installer.install_calls) == before + 4


def test_update_preserves_original_installed_at(tmp_path: Path):
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller()
    install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)

    original = load_records(tmp_path)[0].installed_at

    # A refresh (bundle update) must not rewrite the original install timestamp.
    install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True)

    assert load_records(tmp_path)[0].installed_at == original


def test_refresh_does_not_touch_independently_installed_component(tmp_path: Path):
    # bundle update (refresh) must not re-apply a component installed
    # independently and tracked by no bundle — refreshing it would be a
    # collateral change to something the bundle does not own (FR-022).
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller()
    installer.installed.add(("extensions", "ext-a"))

    result = install_bundle(
        tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
    )

    # ext-a is skipped (not refreshed) and never attributed to the bundle.
    assert ("extensions", "ext-a") not in installer.refresh_calls
    assert ("extensions", "ext-a") in {(c.kind, c.id) for c in result.skipped}
    assert ("extensions", "ext-a") not in {(c.kind, c.id) for c in result.refreshed}
    contributed = {
        (c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
    }
    assert ("extensions", "ext-a") not in contributed


def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
    # A component installed independently (before any bundle) must not be
    # attributed to the bundle, so removing the bundle never uninstalls it
    # (FR-022, no collateral removal).
    make_project(tmp_path)
    manifest = BundleManifest.from_dict(valid_manifest_dict())
    installer = FakeInstaller()
    # Pre-install ext-a independently — no bundle record references it yet.
    installer.installed.add(("extensions", "ext-a"))

    install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)

    contributed = {
        (c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
    }
    assert ("extensions", "ext-a") not in contributed

    remove_bundle(tmp_path, "demo-bundle", installer)
    assert ("extensions", "ext-a") in installer.installed
