"""Backblaze B2 backup agent tests."""

import asyncio
from collections.abc import AsyncGenerator
from io import StringIO
import json
import logging
import threading
import time
from unittest.mock import Mock, patch

from b2sdk._internal.raw_simulator import BucketSimulator
from b2sdk.v2.exception import B2Error
import pytest

from homeassistant.components.backblaze_b2.backup import (
    _parse_metadata,
    async_register_backup_agents_listener,
)
from homeassistant.components.backblaze_b2.const import (
    DATA_BACKUP_AGENT_LISTENERS,
    DOMAIN,
)
from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component

from . import setup_integration
from .const import BACKUP_METADATA, TEST_BACKUP

from tests.common import MockConfigEntry
from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator


@pytest.fixture(autouse=True)
async def setup_backup_integration(
    hass: HomeAssistant,
    mock_config_entry: MockConfigEntry,
) -> AsyncGenerator[None]:
    """Set up integration."""
    with (
        patch("homeassistant.components.backup.is_hassio", return_value=False),
        patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0),
    ):
        assert await async_setup_component(hass, BACKUP_DOMAIN, {})
        await setup_integration(hass, mock_config_entry)
        await hass.async_block_till_done()
        yield


@pytest.fixture
def mock_backup_files():
    """Create standard mock backup file and metadata file."""
    mock_main = Mock()
    mock_main.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"
    mock_main.size = TEST_BACKUP.size
    mock_main.delete = Mock()

    mock_metadata = Mock()
    mock_metadata.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
    mock_metadata.size = 100

    mock_download = Mock()
    mock_response = Mock()
    mock_response.content = json.dumps(BACKUP_METADATA).encode()
    mock_download.response = mock_response
    mock_metadata.download = Mock(return_value=mock_download)
    mock_metadata.delete = Mock()

    return mock_main, mock_metadata


async def test_agents_info(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_config_entry: MockConfigEntry,
) -> None:
    """Test agent info."""
    client = await hass_ws_client(hass)
    await client.send_json_auto_id({"type": "backup/agents/info"})
    response = await client.receive_json()
    assert response["success"]
    assert any(
        agent["agent_id"] == f"{DOMAIN}.{mock_config_entry.entry_id}"
        for agent in response["result"]["agents"]
    )


async def test_agents_list_backups(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test listing backups."""
    client = await hass_ws_client(hass)
    await client.send_json_auto_id({"type": "backup/info"})
    response = await client.receive_json()
    assert response["success"]
    assert "backups" in response["result"]


async def test_agents_get_backup(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test getting backup."""
    client = await hass_ws_client(hass)
    await client.send_json_auto_id(
        {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
    )
    response = await client.receive_json()
    assert response["success"]
    # pylint: disable-next=home-assistant-test-non-deterministic
    if response["result"]["backup"]:
        assert response["result"]["backup"]["backup_id"] == TEST_BACKUP.backup_id


async def test_agents_get_backup_not_found(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test getting nonexistent backup."""
    client = await hass_ws_client(hass)
    await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"})
    response = await client.receive_json()
    assert response["success"]
    assert response["result"]["backup"] is None


async def test_agents_delete(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_backup_files,
) -> None:
    """Test deleting backup."""
    client = await hass_ws_client(hass)
    mock_main, mock_metadata = mock_backup_files

    def mock_ls(_self, _prefix=""):
        return iter([(mock_main, None), (mock_metadata, None)])

    with patch.object(BucketSimulator, "ls", mock_ls):
        await client.send_json_auto_id(
            {"type": "backup/delete", "backup_id": TEST_BACKUP.backup_id}
        )
        response = await client.receive_json()

    assert response["success"]
    assert response["result"] == {"agent_errors": {}}
    mock_main.delete.assert_called_once()
    mock_metadata.delete.assert_called_once()


async def test_agents_delete_not_found(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test deleting nonexistent backup."""
    client = await hass_ws_client(hass)
    await client.send_json_auto_id({"type": "backup/delete", "backup_id": "random"})
    response = await client.receive_json()
    assert response["success"]
    assert response["result"] == {"agent_errors": {}}


async def test_agents_download(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
) -> None:
    """Test downloading backup."""
    client = await hass_client()
    resp = await client.get(
        f"/api/backup/download/{TEST_BACKUP.backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}"
    )
    assert resp.status == 200


async def test_agents_download_not_found(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
) -> None:
    """Test downloading nonexistent backup."""
    client = await hass_client()
    resp = await client.get(
        f"/api/backup/download/nonexistent?agent_id={DOMAIN}.{mock_config_entry.entry_id}"
    )
    assert resp.status == 404


async def test_get_file_for_download_raises_not_found(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
) -> None:
    """Test exception handling for nonexistent backup."""
    client = await hass_client()

    def mock_ls_empty(_self, _prefix=""):
        return iter([])

    with patch.object(BucketSimulator, "ls", mock_ls_empty):
        resp = await client.get(
            f"/api/backup/download/nonexistent?agent_id={DOMAIN}.{mock_config_entry.entry_id}"
        )
        assert resp.status == 404


@pytest.mark.parametrize(
    ("error_type", "exception"),
    [
        ("b2_error", B2Error),
        ("runtime_error", RuntimeError),
    ],
)
async def test_error_during_delete(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_config_entry: MockConfigEntry,
    mock_backup_files,
    error_type: str,
    exception: type[Exception],
) -> None:
    """Test error handling during deletion."""
    client = await hass_ws_client(hass)
    mock_main, mock_metadata = mock_backup_files
    mock_metadata.delete = Mock(side_effect=exception("Delete failed"))

    def mock_ls(_self, _prefix=""):
        return iter([(mock_main, None), (mock_metadata, None)])

    with patch.object(BucketSimulator, "ls", mock_ls):
        await client.send_json_auto_id(
            {"type": "backup/delete", "backup_id": TEST_BACKUP.backup_id}
        )
        response = await client.receive_json()

    assert response["success"]
    assert (
        f"{DOMAIN}.{mock_config_entry.entry_id}" in response["result"]["agent_errors"]
    )


async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None:
    """Test listener cleanup."""
    listener = MagicMock()
    remove_listener = async_register_backup_agents_listener(hass, listener=listener)
    hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener]  # type: ignore[misc]
    remove_listener()
    assert DATA_BACKUP_AGENT_LISTENERS not in hass.data


@pytest.mark.parametrize(
    ("raw_content", "exception", "match"),
    [
        pytest.param(
            "invalid json", ValueError, "Invalid JSON format", id="invalid_json"
        ),
        pytest.param(
            '["not", "a", "dict"]',
            TypeError,
            "JSON content is not a dictionary",
            id="not_a_dict",
        ),
        pytest.param(
            '{"backup_id": "abc", "backup_metadata": {}}',
            ValueError,
            "Missing required metadata keys: metadata_version",
            id="missing_metadata_version",
        ),
        pytest.param(
            '{"metadata_version": "1", "backup_metadata": {}}',
            ValueError,
            "Missing required metadata keys: backup_id",
            id="missing_backup_id",
        ),
        pytest.param(
            '{"metadata_version": "1", "backup_id": "abc"}',
            ValueError,
            "Missing required metadata keys: backup_metadata",
            id="missing_backup_metadata",
        ),
        pytest.param(
            "{}",
            ValueError,
            "Missing required metadata keys: "
            "backup_id, backup_metadata, metadata_version",
            id="empty_dict",
        ),
    ],
)
async def test_parse_metadata_invalid(
    raw_content: str, exception: type[Exception], match: str
) -> None:
    """Test metadata parsing rejects malformed or foreign metadata files."""
    with pytest.raises(exception, match=match):
        _parse_metadata(raw_content)


async def test_agents_list_skips_foreign_metadata_files(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_config_entry: MockConfigEntry,
    mock_backup_files: tuple[Mock, Mock],
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Foreign .metadata.json files in the bucket are skipped, not crash the listing."""
    mock_main, mock_metadata = mock_backup_files

    # Simulate metadata left behind by another bucket-based backup integration.
    foreign_metadata = Mock()
    foreign_metadata.file_name = "testprefix/foreign.metadata.json"
    foreign_download = Mock()
    foreign_response = Mock()
    foreign_response.content = json.dumps({"unrelated": "payload"}).encode()
    foreign_download.response = foreign_response
    foreign_metadata.download = Mock(return_value=foreign_download)

    def mock_ls(_self, _prefix=""):
        return iter(
            [(mock_main, None), (mock_metadata, None), (foreign_metadata, None)]
        )

    client = await hass_ws_client(hass)
    with (
        patch.object(BucketSimulator, "ls", mock_ls),
        caplog.at_level(logging.WARNING),
    ):
        await client.send_json_auto_id({"type": "backup/info"})
        response = await client.receive_json()

    assert response["success"]
    assert response["result"]["agent_errors"] == {}
    backups = response["result"]["backups"]
    assert len(backups) == 1
    assert backups[0]["backup_id"] == TEST_BACKUP.backup_id
    assert "Skipping metadata file testprefix/foreign.metadata.json" in caplog.text


async def test_error_during_list_backups(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_config_entry: MockConfigEntry,
) -> None:
    """Test error handling during list."""
    client = await hass_ws_client(hass)

    def mock_ls_error(_self, _prefix=""):
        raise B2Error("API error")

    with patch.object(BucketSimulator, "ls", mock_ls_error):
        await client.send_json_auto_id({"type": "backup/info"})
        response = await client.receive_json()

    assert response["success"]
    assert (
        f"{DOMAIN}.{mock_config_entry.entry_id}" in response["result"]["agent_errors"]
    )


async def test_error_during_get_backup(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_config_entry: MockConfigEntry,
) -> None:
    """Test error handling during get."""
    client = await hass_ws_client(hass)

    def mock_ls_error(_self, _prefix=""):
        raise B2Error("API error")

    with patch.object(BucketSimulator, "ls", mock_ls_error):
        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": "test_backup"}
        )
        response = await client.receive_json()

    assert response["success"]
    assert (
        f"{DOMAIN}.{mock_config_entry.entry_id}" in response["result"]["agent_errors"]
    )


async def test_metadata_file_download_error_during_list(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test metadata download error handling."""
    client = await hass_ws_client(hass)

    mock_metadata = Mock()
    mock_metadata.file_name = "testprefix/test.metadata.json"
    mock_metadata.download = Mock(side_effect=B2Error("Download failed"))

    mock_tar = Mock()
    mock_tar.file_name = "testprefix/test.tar"

    def mock_ls(_self, _prefix=""):
        return iter([(mock_metadata, None), (mock_tar, None)])

    with patch.object(BucketSimulator, "ls", mock_ls):
        await client.send_json_auto_id({"type": "backup/info"})
        response = await client.receive_json()

    assert response["success"]


async def test_delete_with_metadata_error(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_config_entry: MockConfigEntry,
    mock_backup_files,
) -> None:
    """Test error handling during metadata deletion."""
    client = await hass_ws_client(hass)
    mock_main, mock_metadata = mock_backup_files
    mock_metadata.delete = Mock(side_effect=B2Error("Delete failed"))

    def mock_ls(_self, _prefix=""):
        return iter([(mock_main, None), (mock_metadata, None)])

    with patch.object(BucketSimulator, "ls", mock_ls):
        await client.send_json_auto_id(
            {"type": "backup/delete", "backup_id": TEST_BACKUP.backup_id}
        )
        response = await client.receive_json()

    assert response["success"]
    assert (
        f"{DOMAIN}.{mock_config_entry.entry_id}" in response["result"]["agent_errors"]
    )
    mock_main.delete.assert_called_once()
    mock_metadata.delete.assert_called_once()


async def test_download_backup_not_found(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test downloading nonexistent backup."""
    client = await hass_ws_client(hass)

    def mock_ls_empty(_self, _prefix=""):
        return iter([])

    with patch.object(BucketSimulator, "ls", mock_ls_empty):
        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": "nonexistent"}
        )
        response = await client.receive_json()

    assert response["success"]
    assert response["result"]["backup"] is None


async def test_metadata_file_invalid_json_during_list(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test invalid metadata handling."""
    client = await hass_ws_client(hass)

    mock_metadata = Mock()
    mock_metadata.file_name = "testprefix/bad.metadata.json"
    mock_download = Mock()
    mock_response = Mock()
    mock_response.content = b"not valid json"
    mock_download.response = mock_response
    mock_metadata.download = Mock(return_value=mock_download)

    mock_tar = Mock()
    mock_tar.file_name = "testprefix/bad.tar"

    def mock_ls(_self, _prefix=""):
        return iter([(mock_metadata, None), (mock_tar, None)])

    with (
        patch.object(BucketSimulator, "ls", mock_ls),
        caplog.at_level(logging.WARNING),
    ):
        await client.send_json_auto_id({"type": "backup/info"})
        response = await client.receive_json()

    assert response["success"]


async def test_upload_with_cleanup_and_logging(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test upload logging and cleanup."""
    client = await hass_client()

    with (
        patch(
            "homeassistant.components.backup.manager.BackupManager.async_get_backup",
            return_value=TEST_BACKUP,
        ),
        patch(
            "homeassistant.components.backup.manager.read_backup",
            return_value=TEST_BACKUP,
        ),
        patch("pathlib.Path.open") as mocked_open,
        caplog.at_level(logging.DEBUG),
    ):
        mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
        resp = await client.post(
            f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
            data={"file": StringIO("test")},
        )

    assert resp.status == 201
    assert any("Main backup file upload finished" in msg for msg in caplog.messages)
    assert any("Metadata file upload finished" in msg for msg in caplog.messages)
    assert any("Backup upload complete" in msg for msg in caplog.messages)

    caplog.clear()

    mock_file_info = Mock()
    mock_file_info.delete = Mock()

    with (
        patch(
            "homeassistant.components.backup.manager.BackupManager.async_get_backup",
            return_value=TEST_BACKUP,
        ),
        patch(
            "homeassistant.components.backup.manager.read_backup",
            return_value=TEST_BACKUP,
        ),
        patch("pathlib.Path.open") as mocked_open,
        patch.object(
            BucketSimulator,
            "upload_bytes",
            side_effect=B2Error("Metadata upload failed"),
        ),
        patch.object(
            BucketSimulator, "get_file_info_by_name", return_value=mock_file_info
        ),
        caplog.at_level(logging.DEBUG),
    ):
        mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
        resp = await client.post(
            f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
            data={"file": StringIO("test")},
        )

    assert resp.status == 201
    mock_file_info.delete.assert_called_once()
    assert any(
        "Successfully deleted partially uploaded" in msg for msg in caplog.messages
    )


async def test_upload_with_cleanup_failure(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test upload with cleanup failure when metadata upload fails."""
    client = await hass_client()

    with (
        patch(
            "homeassistant.components.backup.manager.BackupManager.async_get_backup",
            return_value=TEST_BACKUP,
        ),
        patch(
            "homeassistant.components.backup.manager.read_backup",
            return_value=TEST_BACKUP,
        ),
        patch("pathlib.Path.open") as mocked_open,
        patch.object(
            BucketSimulator,
            "upload_bytes",
            side_effect=B2Error("Metadata upload failed"),
        ),
        patch.object(
            BucketSimulator,
            "get_file_info_by_name",
            side_effect=B2Error("Cleanup failed"),
        ),
        caplog.at_level(logging.DEBUG),
    ):
        mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
        resp = await client.post(
            f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
            data={"file": StringIO("test")},
        )

    assert resp.status == 201
    assert any(
        "Failed to clean up partially uploaded backup file" in msg
        for msg in caplog.messages
    )


async def test_tar_upload_failure_skips_cleanup(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test that cleanup is not attempted when tar upload itself fails."""
    client = await hass_client()

    with (
        patch(
            "homeassistant.components.backup.manager.BackupManager.async_get_backup",
            return_value=TEST_BACKUP,
        ),
        patch(
            "homeassistant.components.backup.manager.read_backup",
            return_value=TEST_BACKUP,
        ),
        patch("pathlib.Path.open") as mocked_open,
        patch.object(
            BucketSimulator,
            "upload_unbound_stream",
            side_effect=B2Error("Connection reset"),
        ),
        patch.object(
            BucketSimulator,
            "get_file_info_by_name",
        ) as mock_get_file_info,
        caplog.at_level(logging.DEBUG),
    ):
        mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
        resp = await client.post(
            f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
            data={"file": StringIO("test")},
        )

    assert resp.status == 201
    mock_get_file_info.assert_not_called()
    assert not any(
        "Attempting to delete partially uploaded" in msg for msg in caplog.messages
    )
    assert any("Connection reset" in msg for msg in caplog.messages)


async def test_handle_b2_errors_logs_root_cause(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test that the actual B2 error is logged when upload fails."""
    client = await hass_client()

    with (
        patch(
            "homeassistant.components.backup.manager.BackupManager.async_get_backup",
            return_value=TEST_BACKUP,
        ),
        patch(
            "homeassistant.components.backup.manager.read_backup",
            return_value=TEST_BACKUP,
        ),
        patch("pathlib.Path.open") as mocked_open,
        patch.object(
            BucketSimulator,
            "upload_bytes",
            side_effect=B2Error("Service unavailable"),
        ),
        patch.object(
            BucketSimulator,
            "get_file_info_by_name",
            return_value=Mock(delete=Mock()),
        ),
        caplog.at_level(logging.ERROR),
    ):
        mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
        resp = await client.post(
            f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
            data={"file": StringIO("test")},
        )

    assert resp.status == 201
    assert any(
        "Failed during async_upload_backup: Service unavailable" in msg
        for msg in caplog.messages
    )


async def test_cache_behavior(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test backup list caching."""
    client = await hass_ws_client(hass)

    call_count = []
    original_ls = BucketSimulator.ls

    def ls_with_counter(self, prefix=""):
        call_count.append(1)
        return original_ls(self, prefix)

    with patch.object(BucketSimulator, "ls", ls_with_counter):
        await client.send_json_auto_id({"type": "backup/info"})
        response1 = await client.receive_json()
        assert response1["success"]
        first_call_count = len(call_count)
        assert first_call_count > 0

        await client.send_json_auto_id({"type": "backup/info"})
        response2 = await client.receive_json()
        assert response2["success"]
        assert len(call_count) == first_call_count

        assert response1["result"]["backups"] == response2["result"]["backups"]


async def test_metadata_processing_errors(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test metadata error handling."""
    client = await hass_ws_client(hass)

    mock_metadata = Mock()
    mock_metadata.file_name = "testprefix/test.metadata.json"
    mock_metadata.download = Mock(side_effect=B2Error("Download failed"))

    mock_tar = Mock()
    mock_tar.file_name = "testprefix/test.tar"

    def mock_ls_download_error(_self, _prefix=""):
        return iter([(mock_metadata, None), (mock_tar, None)])

    with (
        patch.object(BucketSimulator, "ls", mock_ls_download_error),
        caplog.at_level(logging.WARNING),
    ):
        await client.send_json_auto_id({"type": "backup/info"})
        response = await client.receive_json()

    assert response["success"]
    assert "backups" in response["result"]

    caplog.clear()

    mock_metadata3 = Mock()
    mock_metadata3.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
    mock_metadata3.download = Mock(side_effect=B2Error("Download failed"))

    mock_tar3 = Mock()
    mock_tar3.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"

    def mock_ls_id_error(_self, _prefix=""):
        return iter([(mock_metadata3, None), (mock_tar3, None)])

    with patch.object(BucketSimulator, "ls", mock_ls_id_error):
        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
        )
        response = await client.receive_json()

    assert response["success"]
    assert response["result"]["backup"] is None

    caplog.clear()

    mock_metadata4 = Mock()
    mock_metadata4.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
    mock_download4 = Mock()
    mock_response4 = Mock()
    mock_response4.content = b"invalid json"
    mock_download4.response = mock_response4
    mock_metadata4.download = Mock(return_value=mock_download4)

    mock_tar4 = Mock()
    mock_tar4.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"

    def mock_ls_invalid_json(_self, _prefix=""):
        return iter([(mock_metadata4, None), (mock_tar4, None)])

    with patch.object(BucketSimulator, "ls", mock_ls_invalid_json):
        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
        )
        response = await client.receive_json()

    assert response["success"]
    assert response["result"]["backup"] is None


async def test_download_triggers_backup_not_found(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
    mock_backup_files,
) -> None:
    """Test race condition where backup disappears during download."""
    client = await hass_client()
    mock_main, mock_metadata = mock_backup_files
    ls_call_count = [0]

    def mock_ls_race_condition(_self, _prefix=""):
        ls_call_count[0] += 1
        if ls_call_count[0] == 1:
            return iter([(mock_main, None), (mock_metadata, None)])
        return iter([])

    with (
        patch.object(BucketSimulator, "ls", mock_ls_race_condition),
        patch("homeassistant.components.backblaze_b2.backup.CACHE_TTL", 0),
    ):
        resp = await client.get(
            f"/api/backup/download/{TEST_BACKUP.backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}"
        )
        assert resp.status == 404


async def test_get_backup_cache_paths(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test cache hit and update paths."""
    client = await hass_ws_client(hass)

    await client.send_json_auto_id({"type": "backup/info"})
    response1 = await client.receive_json()
    assert response1["success"]
    assert len(response1["result"]["backups"]) > 0

    await client.send_json_auto_id(
        {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
    )
    response2 = await client.receive_json()
    assert response2["success"]
    assert response2["result"]["backup"]["backup_id"] == TEST_BACKUP.backup_id

    await client.send_json_auto_id(
        {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
    )
    response3 = await client.receive_json()
    assert response3["success"]
    assert response3["result"]["backup"]["backup_id"] == TEST_BACKUP.backup_id


async def test_metadata_json_parse_error(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
) -> None:
    """Test ValueError handling when metadata JSON parsing fails."""
    client = await hass_ws_client(hass)

    mock_metadata = Mock()
    mock_metadata.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
    mock_download = Mock()
    mock_response = Mock()
    mock_response.content = b"{ invalid json }"
    mock_download.response = mock_response
    mock_metadata.download = Mock(return_value=mock_download)

    mock_tar = Mock()
    mock_tar.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"

    def mock_ls(_self, _prefix=""):
        return iter([(mock_metadata, None), (mock_tar, None)])

    with patch.object(BucketSimulator, "ls", mock_ls):
        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
        )
        response = await client.receive_json()

    assert response["success"]
    assert response["result"]["backup"] is None


async def test_orphaned_metadata_files(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test handling of metadata files without corresponding tar files."""
    client = await hass_ws_client(hass)

    mock_metadata = Mock()
    mock_metadata.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
    mock_download = Mock()
    mock_response = Mock()
    mock_response.content = json.dumps(BACKUP_METADATA).encode()
    mock_download.response = mock_response
    mock_metadata.download = Mock(return_value=mock_download)

    def mock_ls(_self, _prefix=""):
        return iter([(mock_metadata, None)])

    with (
        patch.object(BucketSimulator, "ls", mock_ls),
        caplog.at_level(logging.WARNING),
    ):
        await client.send_json_auto_id({"type": "backup/info"})
        response1 = await client.receive_json()
        assert response1["success"]

        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
        )
        response2 = await client.receive_json()
        assert response2["success"]
        assert response2["result"]["backup"] is None

    assert any(
        "no corresponding backup file" in record.message for record in caplog.records
    )


async def test_get_backup_updates_cache(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_backup_files,
) -> None:
    """Test cache update when metadata initially fails then succeeds."""
    client = await hass_ws_client(hass)
    mock_main, mock_metadata = mock_backup_files
    download_call_count = [0]

    def mock_download():
        download_call_count[0] += 1
        mock_download_obj = Mock()
        mock_response = Mock()
        if download_call_count[0] == 1:
            mock_response.content = b"{ invalid json }"
        else:
            mock_response.content = json.dumps(BACKUP_METADATA).encode()
        mock_download_obj.response = mock_response
        return mock_download_obj

    mock_metadata.download = mock_download

    def mock_ls(_self, _prefix=""):
        return iter([(mock_main, None), (mock_metadata, None)])

    with patch.object(BucketSimulator, "ls", mock_ls):
        await client.send_json_auto_id({"type": "backup/info"})
        response1 = await client.receive_json()
        assert response1["success"]
        assert len(response1["result"]["backups"]) == 0

        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
        )
        response2 = await client.receive_json()
        assert response2["success"]
        assert response2["result"]["backup"]["backup_id"] == TEST_BACKUP.backup_id


async def test_delete_clears_backup_cache(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_backup_files,
) -> None:
    """Test that deleting a backup clears it from cache."""
    client = await hass_ws_client(hass)
    mock_main, mock_metadata = mock_backup_files

    def mock_ls(_self, _prefix=""):
        return iter([(mock_main, None), (mock_metadata, None)])

    with patch.object(BucketSimulator, "ls", mock_ls):
        await client.send_json_auto_id({"type": "backup/info"})
        response1 = await client.receive_json()
        assert response1["success"]
        assert len(response1["result"]["backups"]) > 0

        await client.send_json_auto_id(
            {"type": "backup/delete", "backup_id": TEST_BACKUP.backup_id}
        )
        response2 = await client.receive_json()

    assert response2["success"]
    mock_main.delete.assert_called_once()
    mock_metadata.delete.assert_called_once()


async def test_metadata_downloads_are_sequential(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_config_entry: MockConfigEntry,
) -> None:
    """Test metadata downloads are processed sequentially.

    This avoids exhausting the executor pool.
    """
    current_concurrent = 0
    max_concurrent = 0
    lock = threading.Lock()

    def mock_download_sync():
        nonlocal current_concurrent, max_concurrent
        with lock:
            current_concurrent += 1
            max_concurrent = max(max_concurrent, current_concurrent)
        time.sleep(0.05)
        with lock:
            current_concurrent -= 1

        mock_download_obj = Mock()
        mock_response = Mock()
        mock_response.content = json.dumps(BACKUP_METADATA).encode()
        mock_download_obj.response = mock_response
        return mock_download_obj

    mock_files = []
    for i in range(15):
        mock_metadata = Mock()
        mock_metadata.file_name = f"testprefix/backup{i}.metadata.json"
        mock_metadata.download = mock_download_sync

        mock_tar = Mock()
        mock_tar.file_name = f"testprefix/backup{i}.tar"
        mock_tar.size = TEST_BACKUP.size

        mock_files.extend([(mock_metadata, None), (mock_tar, None)])

    def mock_ls(_self, _prefix=""):
        return iter(mock_files)

    await hass.config_entries.async_unload(mock_config_entry.entry_id)
    await hass.async_block_till_done()

    with patch.object(BucketSimulator, "ls", mock_ls):
        await setup_integration(hass, mock_config_entry)
        await hass.async_block_till_done()

        client = await hass_ws_client(hass)
        await client.send_json_auto_id({"type": "backup/info"})
        response = await client.receive_json()

    assert response["success"]
    # Verify downloads were sequential (max 1 at a time)
    assert max_concurrent == 1


async def test_upload_timeout(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test upload timeout handling."""
    client = await hass_client()

    mock_file_info = Mock()
    mock_file_info.delete = Mock()

    with (
        patch(
            "homeassistant.components.backup.manager.BackupManager.async_get_backup",
            return_value=TEST_BACKUP,
        ),
        patch(
            "homeassistant.components.backup.manager.read_backup",
            return_value=TEST_BACKUP,
        ),
        patch("pathlib.Path.open") as mocked_open,
        patch(
            "homeassistant.components.backblaze_b2.backup.BackblazeBackupAgent._upload_unbound_stream_sync",
        ),
        patch(
            "homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
            side_effect=TimeoutError,
        ),
        patch.object(
            BucketSimulator,
            "get_file_info_by_name",
            return_value=mock_file_info,
        ),
        caplog.at_level(logging.ERROR),
    ):
        mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
        resp = await client.post(
            f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
            data={"file": StringIO("test")},
        )

    assert resp.status == 201
    assert any("timed out" in msg for msg in caplog.messages)


async def test_upload_cancelled(
    hass_client: ClientSessionGenerator,
    mock_config_entry: MockConfigEntry,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test upload cancellation handling."""
    client = await hass_client()

    mock_file_info = Mock()
    mock_file_info.delete = Mock()

    with (
        patch(
            "homeassistant.components.backup.manager.BackupManager.async_get_backup",
            return_value=TEST_BACKUP,
        ),
        patch(
            "homeassistant.components.backup.manager.read_backup",
            return_value=TEST_BACKUP,
        ),
        patch("pathlib.Path.open") as mocked_open,
        patch(
            "homeassistant.components.backblaze_b2.backup.BackblazeBackupAgent._upload_unbound_stream_sync",
        ),
        patch(
            "homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
            side_effect=asyncio.CancelledError,
        ),
        patch.object(
            BucketSimulator,
            "get_file_info_by_name",
            return_value=mock_file_info,
        ),
        caplog.at_level(logging.WARNING),
    ):
        mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
        resp = await client.post(
            f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}",
            data={"file": StringIO("test")},
        )

    # CancelledError propagates up and causes a 500 error
    assert resp.status == 500
    assert any("cancelled" in msg for msg in caplog.messages)


async def test_metadata_download_timeout_during_list(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test that metadata download timeout during list is handled gracefully."""
    client = await hass_ws_client(hass)

    mock_metadata = Mock()
    mock_metadata.file_name = "testprefix/slow.metadata.json"

    mock_tar = Mock()
    mock_tar.file_name = "testprefix/slow.tar"
    mock_tar.size = TEST_BACKUP.size

    def mock_ls(_self, _prefix=""):
        return iter([(mock_metadata, None), (mock_tar, None)])

    with (
        patch.object(BucketSimulator, "ls", mock_ls),
        patch(
            "homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
            side_effect=TimeoutError,
        ),
        caplog.at_level(logging.WARNING),
    ):
        await client.send_json_auto_id({"type": "backup/info"})
        response = await client.receive_json()

    assert response["success"]
    # The backup should not appear in the list due to timeout
    assert len(response["result"]["backups"]) == 0
    assert any("Timeout downloading metadata file" in msg for msg in caplog.messages)


async def test_metadata_download_timeout_during_find_by_id(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test that metadata download timeout during find by ID is handled gracefully."""
    client = await hass_ws_client(hass)

    mock_metadata = Mock()
    mock_metadata.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"

    mock_tar = Mock()
    mock_tar.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"
    mock_tar.size = TEST_BACKUP.size

    def mock_ls(_self, _prefix=""):
        return iter([(mock_metadata, None), (mock_tar, None)])

    with (
        patch.object(BucketSimulator, "ls", mock_ls),
        patch(
            "homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
            side_effect=TimeoutError,
        ),
        caplog.at_level(logging.WARNING),
    ):
        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
        )
        response = await client.receive_json()

    assert response["success"]
    # The backup should not be found due to timeout
    assert response["result"]["backup"] is None
    assert any(
        "Timeout downloading metadata file" in msg
        and "while searching for backup" in msg
        for msg in caplog.messages
    )


async def test_metadata_timeout_does_not_block_healthy_backups(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test that a timed out metadata download doesn't prevent listing other backups."""
    client = await hass_ws_client(hass)

    mock_hanging_metadata = Mock()
    mock_hanging_metadata.file_name = "testprefix/hanging_backup.metadata.json"
    mock_hanging_metadata.download = Mock(side_effect=B2Error("SSL failure"))

    mock_hanging_tar = Mock()
    mock_hanging_tar.file_name = "testprefix/hanging_backup.tar"
    mock_hanging_tar.size = 1000

    mock_healthy_metadata = Mock()
    mock_healthy_metadata.file_name = (
        f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
    )
    mock_healthy_download = Mock()
    mock_healthy_response = Mock()
    mock_healthy_response.content = json.dumps(BACKUP_METADATA).encode()
    mock_healthy_download.response = mock_healthy_response
    mock_healthy_metadata.download = Mock(return_value=mock_healthy_download)

    mock_healthy_tar = Mock()
    mock_healthy_tar.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"
    mock_healthy_tar.size = TEST_BACKUP.size

    def mock_ls(_self, _prefix=""):
        return iter(
            [
                (mock_hanging_metadata, None),
                (mock_hanging_tar, None),
                (mock_healthy_metadata, None),
                (mock_healthy_tar, None),
            ]
        )

    call_count = 0
    original_wait_for = asyncio.wait_for

    async def wait_for_first_timeout(coro, *, timeout=None):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise TimeoutError
        return await original_wait_for(coro, timeout=timeout)

    with (
        patch.object(BucketSimulator, "ls", mock_ls),
        patch(
            "homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
            wait_for_first_timeout,
        ),
        caplog.at_level(logging.WARNING),
    ):
        await client.send_json_auto_id({"type": "backup/info"})
        response = await client.receive_json()

    assert response["success"]
    backups = response["result"]["backups"]
    assert len(backups) == 1
    assert backups[0]["backup_id"] == TEST_BACKUP.backup_id
    assert any("Timeout downloading metadata file" in msg for msg in caplog.messages)


async def test_metadata_download_timeout_during_get_backup(
    hass: HomeAssistant,
    hass_ws_client: WebSocketGenerator,
    mock_config_entry: MockConfigEntry,
) -> None:
    """Test timeout on metadata re-download after file is found."""
    client = await hass_ws_client(hass)

    mock_metadata = Mock()
    mock_metadata.file_name = f"testprefix/{TEST_BACKUP.backup_id}.metadata.json"
    mock_download = Mock()
    mock_response = Mock()
    mock_response.content = json.dumps(BACKUP_METADATA).encode()
    mock_download.response = mock_response
    mock_metadata.download = Mock(return_value=mock_download)

    mock_tar = Mock()
    mock_tar.file_name = f"testprefix/{TEST_BACKUP.backup_id}.tar"
    mock_tar.size = TEST_BACKUP.size

    def mock_ls(_self, _prefix=""):
        return iter([(mock_metadata, None), (mock_tar, None)])

    call_count = 0
    original_wait_for = asyncio.wait_for

    async def wait_for_second_timeout(coro, *, timeout=None):
        nonlocal call_count
        call_count += 1
        if call_count >= 2:
            raise TimeoutError
        return await original_wait_for(coro, timeout=timeout)

    with (
        patch.object(BucketSimulator, "ls", mock_ls),
        patch(
            "homeassistant.components.backblaze_b2.backup.asyncio.wait_for",
            wait_for_second_timeout,
        ),
        patch("homeassistant.components.backblaze_b2.backup.CACHE_TTL", 0),
    ):
        await client.send_json_auto_id(
            {"type": "backup/details", "backup_id": TEST_BACKUP.backup_id}
        )
        response = await client.receive_json()

    assert response["success"]
    assert (
        f"{DOMAIN}.{mock_config_entry.entry_id}" in response["result"]["agent_errors"]
    )
