"""The deployment directory: which release is live, and what happened to it."""

from __future__ import annotations

import os
from pathlib import Path

import pytest

from app.release.layout import POINTER_NAME, Layout, LayoutError, State


@pytest.fixture
def layout(tmp_path: Path) -> Layout:
    layout = Layout(tmp_path / "deploy")
    layout.prepare()

    for version in ("1.0.0", "1.1.0"):
        layout.release(version).mkdir()

    return layout


class TestThePointer:
    def test_a_fresh_deployment_has_nothing_live(self, tmp_path):
        assert Layout(tmp_path / "empty").current_version() is None

    def test_pointing_at_a_release_makes_it_live(self, layout):
        layout.point_to("1.0.0")

        assert layout.current_version() == "1.0.0"
        assert layout.live_path() == layout.release("1.0.0")

    def test_switching_replaces_the_previous_pointer(self, layout):
        layout.point_to("1.0.0")
        layout.point_to("1.1.0")

        assert layout.current_version() == "1.1.0"

    def test_pointing_at_something_not_installed_is_refused(self, layout):
        with pytest.raises(LayoutError, match="not installed"):
            layout.point_to("9.9.9")

    def test_the_previous_release_is_left_alone(self, layout):
        (layout.release("1.0.0") / "file.txt").write_text("kept", encoding="utf-8")
        layout.point_to("1.0.0")
        layout.point_to("1.1.0")

        # This is what makes a rollback a pointer change rather than a reinstall.
        assert (layout.release("1.0.0") / "file.txt").read_text(encoding="utf-8") == "kept"

    def test_a_pointer_file_is_read_when_there_is_no_symlink(self, layout):
        """The fallback for a host without symlink privileges — Windows without
        Developer Mode. Both forms have to be readable, because a deployment can
        move between them."""
        (layout.root / POINTER_NAME).write_text("1.1.0", encoding="utf-8")

        assert layout.current_version() == "1.1.0"

    def test_a_symlink_wins_over_a_stale_pointer_file(self, layout):
        """The symlink is what a process actually follows. If the two disagree,
        reading the file would report a version that is not running."""
        (layout.root / POINTER_NAME).write_text("1.0.0", encoding="utf-8")
        layout.point_to("1.1.0")

        assert layout.current_version() == "1.1.0"

    def test_falling_back_to_a_pointer_file_clears_the_symlink(self, layout, monkeypatch):
        """The bug this was written for.

        `current_version()` reads the symlink before the pointer file, because
        the symlink is what a process follows. So a fallback that writes a
        pointer file and leaves a stale symlink behind makes the switch silently
        do nothing — the deployment goes on reporting, and running, the version
        it used to be on.
        """
        layout.point_to("1.0.0")

        monkeypatch.setattr(layout, "_link_to", lambda target: False)
        layout.point_to("1.1.0")

        assert layout.current_version() == "1.1.0"
        assert not layout.current.is_symlink()

    def test_the_two_pointer_forms_never_disagree(self, layout, monkeypatch):
        monkeypatch.setattr(layout, "_link_to", lambda target: False)
        layout.point_to("1.0.0")

        monkeypatch.undo()
        layout.point_to("1.1.0")

        assert layout.current_version() == "1.1.0"
        assert not (layout.root / POINTER_NAME).exists()

    @pytest.mark.skipif(os.name == "nt", reason="symlink behaviour differs on Windows")
    def test_switching_leaves_no_moment_without_a_pointer(self, layout):
        """`os.replace` over the existing link, rather than unlink-then-create.
        A process starting in that gap would not find the application at all."""
        layout.point_to("1.0.0")
        layout.point_to("1.1.0")

        assert layout.current.exists()


class TestState:
    def test_a_missing_state_file_reads_as_empty(self, layout):
        assert layout.state().version is None

    def test_it_round_trips(self, layout):
        state = State(version="1.1.0", previous="1.0.0")
        state.record("install", "1.1.0", "installed", "restarted")
        layout.save(state)

        reloaded = layout.state()

        assert reloaded.version == "1.1.0"
        assert reloaded.previous == "1.0.0"
        assert reloaded.history[0].outcome == "installed"

    def test_corruption_does_not_stop_an_install(self, layout):
        """It is a record, not the source of truth — the pointer is. A rollback
        failing because a JSON file got truncated would be the worst possible
        time for it."""
        layout.state_file.write_text("{ not json", encoding="utf-8")

        assert layout.state().version is None

    def test_the_newest_event_is_first(self, layout):
        state = State()
        state.record("install", "1.0.0", "installed")
        state.record("install", "1.1.0", "installed")

        assert state.history[0].version == "1.1.0"

    def test_history_is_bounded(self, layout):
        """A deployment updated weekly for five years would otherwise grow a
        file nobody prunes."""
        state = State()

        for i in range(State.HISTORY_LIMIT + 20):
            state.record("install", f"1.0.{i}", "installed")

        assert len(state.history) == State.HISTORY_LIMIT

    def test_a_saved_state_is_readable_json(self, layout):
        import json

        state = State(version="1.0.0")
        state.record("install", "1.0.0", "installed", "detail")
        layout.save(state)

        parsed = json.loads(layout.state_file.read_text(encoding="utf-8"))

        assert parsed["version"] == "1.0.0"
        assert parsed["history"][0]["action"] == "install"


class TestDirectories:
    def test_prepare_is_safe_to_repeat(self, layout):
        layout.prepare()
        layout.prepare()

        assert layout.releases.is_dir()
        assert layout.shared.is_dir()

    def test_installed_lists_the_releases(self, layout):
        assert layout.installed() == ["1.0.0", "1.1.0"]

    def test_installed_is_empty_before_anything_is_installed(self, tmp_path):
        assert Layout(tmp_path / "nothing").installed() == []

    def test_configuration_lives_outside_every_release(self, layout):
        """What makes a rollback a pointer change: the restored release is still
        pointing at the same configuration it always used."""
        assert layout.shared.parent == layout.root
        assert layout.shared not in layout.release("1.0.0").parents
