"""Unit tests for bulk_operations module."""

from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from teams_phone.exceptions import ConfigurationError, NotFoundError, ValidationError
from teams_phone.models import AssignmentStatus, NumberType

# Import directly from bulk_operations module to avoid circular imports through __init__
from teams_phone.services.bulk_operations import (
    BulkAssignResult,
    BulkAssignRow,
    BulkOperations,
    BulkPolicyResult,
    BulkPolicyRow,
    BulkPolicyValidationResult,
    BulkValidationResult,
    parse_assign_csv,
    parse_policy_csv,
    write_policy_results_csv,
    write_results_csv,
)

# Sample CSV content for tests
VALID_CSV = """user,phone_number,location
user1@contoso.com,+14255551234,loc-001
user2@contoso.com,+14255555678,loc-002"""

VALID_CSV_NO_LOCATION = """user,phone_number
user1@contoso.com,+14255551234
user2@contoso.com,+14255555678"""

VALID_CSV_WITH_BOM = (
    "\ufeffuser,phone_number,location\nuser1@contoso.com,+14255551234,loc-001"
)

EMPTY_REQUIRED_FIELDS_CSV = """user,phone_number,location
,+14255551234,loc-001
user2@contoso.com,,loc-002
,,loc-003"""

EMPTY_LOCATION_CSV = """user,phone_number,location
user1@contoso.com,+14255551234,
user2@contoso.com,+14255555678,"""

MISSING_USER_COLUMN_CSV = """phone_number,location
+14255551234,loc-001"""

MISSING_PHONE_COLUMN_CSV = """user,location
user1@contoso.com,loc-001"""

MISSING_BOTH_COLUMNS_CSV = """location,other
loc-001,value"""

HEADER_ONLY_CSV = """user,phone_number,location"""

EMPTY_FILE_CSV = ""


class TestBulkAssignRow:
    """Tests for BulkAssignRow dataclass."""

    @pytest.mark.unit
    def test_dataclass_creates_with_all_fields(self) -> None:
        """BulkAssignRow stores all provided fields."""
        row = BulkAssignRow(
            row_number=2,
            user="user@contoso.com",
            phone_number="+14255551234",
            location="loc-001",
            errors=["error1"],
            warnings=["warning1"],
        )

        assert row.row_number == 2
        assert row.user == "user@contoso.com"
        assert row.phone_number == "+14255551234"
        assert row.location == "loc-001"
        assert row.errors == ["error1"]
        assert row.warnings == ["warning1"]

    @pytest.mark.unit
    def test_dataclass_default_empty_lists(self) -> None:
        """BulkAssignRow defaults errors and warnings to empty lists."""
        row = BulkAssignRow(
            row_number=2,
            user="user@contoso.com",
            phone_number="+14255551234",
            location=None,
        )

        assert row.errors == []
        assert row.warnings == []

    @pytest.mark.unit
    def test_dataclass_mutable_defaults_independent(self) -> None:
        """BulkAssignRow instances have independent error/warning lists."""
        row1 = BulkAssignRow(
            row_number=2,
            user="user1@contoso.com",
            phone_number="+14255551234",
            location=None,
        )
        row2 = BulkAssignRow(
            row_number=3,
            user="user2@contoso.com",
            phone_number="+14255555678",
            location=None,
        )

        row1.errors.append("error for row1")

        assert row1.errors == ["error for row1"]
        assert row2.errors == []


class TestBulkValidationResult:
    """Tests for BulkValidationResult dataclass."""

    @pytest.mark.unit
    def test_dataclass_creates_with_all_fields(self) -> None:
        """BulkValidationResult stores all provided fields."""
        rows = [
            BulkAssignRow(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                location=None,
            )
        ]
        result = BulkValidationResult(
            rows=rows,
            valid_count=1,
            error_count=0,
            warning_count=0,
        )

        assert result.rows == rows
        assert result.valid_count == 1
        assert result.error_count == 0
        assert result.warning_count == 0


class TestParseAssignCsv:
    """Tests for parse_assign_csv function."""

    @pytest.mark.unit
    def test_parses_valid_csv_with_all_columns(self, tmp_path: Path) -> None:
        """parse_assign_csv parses CSV with all columns correctly."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(VALID_CSV, encoding="utf-8")

        rows = parse_assign_csv(csv_file)

        assert len(rows) == 2
        assert rows[0].row_number == 2
        assert rows[0].user == "user1@contoso.com"
        assert rows[0].phone_number == "+14255551234"
        assert rows[0].location == "loc-001"
        assert rows[0].errors == []
        assert rows[0].warnings == []

        assert rows[1].row_number == 3
        assert rows[1].user == "user2@contoso.com"
        assert rows[1].phone_number == "+14255555678"
        assert rows[1].location == "loc-002"

    @pytest.mark.unit
    def test_parses_csv_without_location_column(self, tmp_path: Path) -> None:
        """parse_assign_csv handles CSV without optional location column."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(VALID_CSV_NO_LOCATION, encoding="utf-8")

        rows = parse_assign_csv(csv_file)

        assert len(rows) == 2
        assert rows[0].location is None
        assert rows[0].warnings == []  # No warning when column doesn't exist

    @pytest.mark.unit
    def test_handles_utf8_bom(self, tmp_path: Path) -> None:
        """parse_assign_csv handles UTF-8 BOM from PowerShell exports."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(VALID_CSV_WITH_BOM, encoding="utf-8")

        rows = parse_assign_csv(csv_file)

        assert len(rows) == 1
        assert rows[0].user == "user1@contoso.com"

    @pytest.mark.unit
    def test_collects_errors_for_empty_required_fields(self, tmp_path: Path) -> None:
        """parse_assign_csv collects errors when required fields are empty."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(EMPTY_REQUIRED_FIELDS_CSV, encoding="utf-8")

        rows = parse_assign_csv(csv_file)

        assert len(rows) == 3
        # Row 2: missing user
        assert "Missing required field: user" in rows[0].errors
        assert "Missing required field: phone_number" not in rows[0].errors

        # Row 3: missing phone_number
        assert "Missing required field: phone_number" in rows[1].errors
        assert "Missing required field: user" not in rows[1].errors

        # Row 4: missing both
        assert "Missing required field: user" in rows[2].errors
        assert "Missing required field: phone_number" in rows[2].errors

    @pytest.mark.unit
    def test_generates_warning_for_empty_location(self, tmp_path: Path) -> None:
        """parse_assign_csv generates warning when location column is empty."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(EMPTY_LOCATION_CSV, encoding="utf-8")

        rows = parse_assign_csv(csv_file)

        assert len(rows) == 2
        assert "Empty optional field: location" in rows[0].warnings
        assert "Empty optional field: location" in rows[1].warnings
        assert rows[0].location is None
        assert rows[1].location is None

    @pytest.mark.unit
    def test_raises_validation_error_missing_user_column(self, tmp_path: Path) -> None:
        """parse_assign_csv raises ValidationError when user column missing."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(MISSING_USER_COLUMN_CSV, encoding="utf-8")

        with pytest.raises(ValidationError) as exc_info:
            parse_assign_csv(csv_file)

        assert "user" in str(exc_info.value)
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_raises_validation_error_missing_phone_column(self, tmp_path: Path) -> None:
        """parse_assign_csv raises ValidationError when phone_number column missing."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(MISSING_PHONE_COLUMN_CSV, encoding="utf-8")

        with pytest.raises(ValidationError) as exc_info:
            parse_assign_csv(csv_file)

        assert "phone_number" in str(exc_info.value)
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_raises_validation_error_missing_both_required_columns(
        self, tmp_path: Path
    ) -> None:
        """parse_assign_csv raises ValidationError when both required columns missing."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(MISSING_BOTH_COLUMNS_CSV, encoding="utf-8")

        with pytest.raises(ValidationError) as exc_info:
            parse_assign_csv(csv_file)

        assert "phone_number" in str(exc_info.value)
        assert "user" in str(exc_info.value)

    @pytest.mark.unit
    def test_returns_empty_list_for_header_only_csv(self, tmp_path: Path) -> None:
        """parse_assign_csv returns empty list when CSV has only header."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(HEADER_ONLY_CSV, encoding="utf-8")

        rows = parse_assign_csv(csv_file)

        assert rows == []

    @pytest.mark.unit
    def test_raises_validation_error_for_empty_file(self, tmp_path: Path) -> None:
        """parse_assign_csv raises ValidationError for empty file."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(EMPTY_FILE_CSV, encoding="utf-8")

        with pytest.raises(ValidationError) as exc_info:
            parse_assign_csv(csv_file)

        assert "empty" in str(exc_info.value).lower()

    @pytest.mark.unit
    def test_raises_validation_error_for_missing_file(self, tmp_path: Path) -> None:
        """parse_assign_csv raises ValidationError when file doesn't exist."""
        csv_file = tmp_path / "nonexistent.csv"

        with pytest.raises(ValidationError) as exc_info:
            parse_assign_csv(csv_file)

        assert "not found" in str(exc_info.value).lower()
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_row_numbers_are_1_indexed(self, tmp_path: Path) -> None:
        """parse_assign_csv uses 1-indexed row numbers starting after header."""
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(VALID_CSV, encoding="utf-8")

        rows = parse_assign_csv(csv_file)

        # Header is row 1, data starts at row 2
        assert rows[0].row_number == 2
        assert rows[1].row_number == 3

    @pytest.mark.unit
    def test_strips_whitespace_from_values(self, tmp_path: Path) -> None:
        """parse_assign_csv strips leading/trailing whitespace from values."""
        csv_content = """user,phone_number,location
  user1@contoso.com  ,  +14255551234  ,  loc-001  """
        csv_file = tmp_path / "bulk.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        rows = parse_assign_csv(csv_file)

        assert rows[0].user == "user1@contoso.com"
        assert rows[0].phone_number == "+14255551234"
        assert rows[0].location == "loc-001"


def make_number_assignment(
    phone_number: str = "+14255551234",
    assignment_status: AssignmentStatus = AssignmentStatus.UNASSIGNED,
    number_type: NumberType = NumberType.DIRECT_ROUTING,
) -> MagicMock:
    """Create a mock NumberAssignment for testing."""
    number = MagicMock()
    number.telephone_number = phone_number
    number.assignment_status = assignment_status
    number.number_type = number_type
    return number


@pytest.fixture
def mock_user_service() -> MagicMock:
    """Create a mock UserService for testing."""
    service = MagicMock()
    service.resolve_user = AsyncMock()
    return service


@pytest.fixture
def mock_number_service() -> MagicMock:
    """Create a mock NumberService for testing."""
    service = MagicMock()
    service.get_number = AsyncMock()
    return service


@pytest.fixture
def mock_location_service() -> MagicMock:
    """Create a mock LocationService for testing."""
    service = MagicMock()
    service.validate_location = MagicMock()
    service.is_cache_stale = MagicMock(return_value=False)
    return service


class TestBulkOperations:
    """Tests for BulkOperations class."""

    @pytest.mark.unit
    def test_init_stores_services(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """BulkOperations stores injected services."""
        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )

        assert ops.user_service is mock_user_service
        assert ops.number_service is mock_number_service
        assert ops.location_service is mock_location_service


class TestValidateAssignCsv:
    """Tests for BulkOperations.validate_assign_csv method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_valid_row_passes_validation(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv passes valid rows without errors."""
        mock_user_service.resolve_user.return_value = MagicMock()
        mock_number_service.get_number.return_value = make_number_assignment()

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                location="loc-001",
            )
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.valid_count == 1
        assert result.error_count == 0
        assert rows[0].errors == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_user_not_found_adds_error(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv adds error when user not found."""
        mock_user_service.resolve_user.side_effect = NotFoundError(
            "User 'unknown@contoso.com' not found"
        )
        mock_number_service.get_number.return_value = make_number_assignment()

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="unknown@contoso.com",
                phone_number="+14255551234",
                location=None,
            )
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.error_count == 1
        assert result.valid_count == 0
        assert "User 'unknown@contoso.com' not found" in rows[0].errors

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_ambiguous_user_adds_error(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv adds error for ambiguous user match."""
        mock_user_service.resolve_user.side_effect = ValidationError(
            "Multiple users match 'John'"
        )
        mock_number_service.get_number.return_value = make_number_assignment()

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="John",
                phone_number="+14255551234",
                location=None,
            )
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.error_count == 1
        assert any("Ambiguous user" in err for err in rows[0].errors)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_number_already_assigned_adds_error(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv adds error when number is already assigned."""
        mock_user_service.resolve_user.return_value = MagicMock()
        mock_number_service.get_number.return_value = make_number_assignment(
            assignment_status=AssignmentStatus.USER_ASSIGNED
        )

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                location=None,
            )
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.error_count == 1
        assert "already assigned" in rows[0].errors[0]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_number_not_in_inventory_adds_error(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv adds error when number not in inventory."""
        mock_user_service.resolve_user.return_value = MagicMock()
        mock_number_service.get_number.side_effect = NotFoundError(
            "Phone number '+14255551234' not found"
        )

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                location=None,
            )
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.error_count == 1
        assert "not in inventory" in rows[0].errors[0]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_invalid_location_adds_error(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv adds error for invalid location."""
        mock_user_service.resolve_user.return_value = MagicMock()
        mock_number_service.get_number.return_value = make_number_assignment()
        mock_location_service.validate_location.side_effect = NotFoundError(
            "Location 'invalid-loc' not found"
        )

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                location="invalid-loc",
            )
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.error_count == 1
        assert "Location 'invalid-loc' not found" in rows[0].errors

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_stale_cache_adds_warning(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv adds warning when location cache is stale."""
        mock_user_service.resolve_user.return_value = MagicMock()
        mock_number_service.get_number.return_value = make_number_assignment()
        mock_location_service.is_cache_stale.return_value = True

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                location="loc-001",
            )
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.warning_count == 1
        assert any("cache is stale" in w for w in rows[0].warnings)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_calling_plan_without_location_adds_warning(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv warns about Calling Plan numbers without location."""
        mock_user_service.resolve_user.return_value = MagicMock()
        mock_number_service.get_number.return_value = make_number_assignment(
            number_type=NumberType.CALLING_PLAN
        )

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                location=None,
            )
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.warning_count == 1
        assert any("Calling Plan" in w for w in rows[0].warnings)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_skips_validation_for_rows_with_parsing_errors(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv skips rows that already have parsing errors."""
        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="",
                phone_number="+14255551234",
                location=None,
                errors=["Missing required field: user"],
            )
        ]

        result = await ops.validate_assign_csv(rows)

        # Should not call any services for rows with errors
        mock_user_service.resolve_user.assert_not_called()
        mock_number_service.get_number.assert_not_called()
        assert result.error_count == 1
        assert result.valid_count == 0

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_counts_computed_correctly(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv computes valid, error, and warning counts correctly."""
        mock_user_service.resolve_user.return_value = MagicMock()

        # First call succeeds (unassigned), second fails (not found)
        mock_number_service.get_number.side_effect = [
            make_number_assignment(number_type=NumberType.CALLING_PLAN),
            NotFoundError("Not found"),
            make_number_assignment(),
        ]

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            # Valid but has warning (Calling Plan without location)
            BulkAssignRow(
                row_number=2,
                user="user1@contoso.com",
                phone_number="+14255551234",
                location=None,
            ),
            # Error: number not in inventory
            BulkAssignRow(
                row_number=3,
                user="user2@contoso.com",
                phone_number="+14255555678",
                location=None,
            ),
            # Valid with no warnings
            BulkAssignRow(
                row_number=4,
                user="user3@contoso.com",
                phone_number="+14255559999",
                location="loc-001",
            ),
        ]

        result = await ops.validate_assign_csv(rows)

        assert result.valid_count == 2  # Row 1 and 3 (no errors)
        assert result.error_count == 1  # Row 2 (number not found)
        assert result.warning_count == 1  # Row 1 (Calling Plan without location)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_multiple_errors_per_row(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv can collect multiple errors per row."""
        mock_user_service.resolve_user.side_effect = NotFoundError("User not found")
        mock_number_service.get_number.side_effect = NotFoundError("Number not found")
        mock_location_service.validate_location.side_effect = NotFoundError(
            "Location not found"
        )

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="unknown@contoso.com",
                phone_number="+14255551234",
                location="invalid-loc",
            )
        ]

        result = await ops.validate_assign_csv(rows)

        # Should have 3 errors: user, number, and location
        assert len(rows[0].errors) == 3
        assert result.error_count == 1  # Still one row with errors

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_empty_rows_returns_zero_counts(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv handles empty row list."""
        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows: list[BulkAssignRow] = []

        result = await ops.validate_assign_csv(rows)

        assert result.valid_count == 0
        assert result.error_count == 0
        assert result.warning_count == 0
        assert result.rows == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_location_not_validated_when_none(
        self,
        mock_user_service: MagicMock,
        mock_number_service: MagicMock,
        mock_location_service: MagicMock,
    ) -> None:
        """validate_assign_csv does not validate location when not provided."""
        mock_user_service.resolve_user.return_value = MagicMock()
        mock_number_service.get_number.return_value = make_number_assignment()

        ops = BulkOperations(
            mock_user_service, mock_number_service, mock_location_service
        )
        rows = [
            BulkAssignRow(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                location=None,
            )
        ]

        await ops.validate_assign_csv(rows)

        mock_location_service.validate_location.assert_not_called()


class TestBulkAssignResult:
    """Tests for BulkAssignResult dataclass."""

    @pytest.mark.unit
    def test_dataclass_creates_with_all_fields(self) -> None:
        """BulkAssignResult stores all provided fields."""
        result = BulkAssignResult(
            row_number=2,
            user="user@contoso.com",
            phone_number="+14255551234",
            status="success",
            error="Some error",
        )

        assert result.row_number == 2
        assert result.user == "user@contoso.com"
        assert result.phone_number == "+14255551234"
        assert result.status == "success"
        assert result.error == "Some error"

    @pytest.mark.unit
    def test_dataclass_default_error_none(self) -> None:
        """BulkAssignResult defaults error to None."""
        result = BulkAssignResult(
            row_number=2,
            user="user@contoso.com",
            phone_number="+14255551234",
            status="success",
        )

        assert result.error is None

    @pytest.mark.unit
    def test_dataclass_accepts_different_statuses(self) -> None:
        """BulkAssignResult accepts success, failed, and skipped statuses."""
        success = BulkAssignResult(
            row_number=2,
            user="user1@contoso.com",
            phone_number="+14255551234",
            status="success",
        )
        failed = BulkAssignResult(
            row_number=3,
            user="user2@contoso.com",
            phone_number="+14255555678",
            status="failed",
            error="User not found",
        )
        skipped = BulkAssignResult(
            row_number=4,
            user="user3@contoso.com",
            phone_number="+14255559999",
            status="skipped",
            error="Missing required field",
        )

        assert success.status == "success"
        assert failed.status == "failed"
        assert skipped.status == "skipped"


class TestWriteResultsCsv:
    """Tests for write_results_csv function."""

    @pytest.mark.unit
    def test_writes_valid_results_file(self, tmp_path: Path) -> None:
        """write_results_csv creates a valid CSV file with results."""
        output_file = tmp_path / "results.csv"
        results = [
            BulkAssignResult(
                row_number=2,
                user="user1@contoso.com",
                phone_number="+14255551234",
                status="success",
            ),
            BulkAssignResult(
                row_number=3,
                user="user2@contoso.com",
                phone_number="+14255555678",
                status="failed",
                error="User not found",
            ),
        ]

        write_results_csv(results, output_file)

        assert output_file.exists()
        content = output_file.read_text(encoding="utf-8")
        lines = content.strip().split("\n")

        assert len(lines) == 3  # Header + 2 data rows
        assert lines[0] == "row,user,phone_number,status,error"
        assert lines[1] == "2,user1@contoso.com,+14255551234,success,"
        assert lines[2] == "3,user2@contoso.com,+14255555678,failed,User not found"

    @pytest.mark.unit
    def test_writes_utf8_without_bom(self, tmp_path: Path) -> None:
        """write_results_csv writes UTF-8 without BOM."""
        output_file = tmp_path / "results.csv"
        results = [
            BulkAssignResult(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                status="success",
            )
        ]

        write_results_csv(results, output_file)

        # Read raw bytes and check for BOM
        raw_content = output_file.read_bytes()
        assert not raw_content.startswith(b"\xef\xbb\xbf")  # UTF-8 BOM
        assert raw_content.startswith(b"row,")

    @pytest.mark.unit
    def test_creates_parent_directory(self, tmp_path: Path) -> None:
        """write_results_csv creates parent directories if they don't exist."""
        output_file = tmp_path / "nested" / "output" / "results.csv"
        results = [
            BulkAssignResult(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                status="success",
            )
        ]

        write_results_csv(results, output_file)

        assert output_file.exists()
        assert output_file.parent.exists()

    @pytest.mark.unit
    def test_permission_error_on_mkdir_raises_configuration_error(
        self, tmp_path: Path
    ) -> None:
        """write_results_csv raises ConfigurationError on permission failure for mkdir."""
        output_file = tmp_path / "restricted" / "results.csv"
        results = [
            BulkAssignResult(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                status="success",
            )
        ]

        with patch.object(Path, "mkdir", side_effect=PermissionError("Access denied")):
            with pytest.raises(ConfigurationError) as exc_info:
                write_results_csv(results, output_file)

        assert "Cannot create output directory" in str(exc_info.value)
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_permission_error_on_write_raises_configuration_error(
        self, tmp_path: Path
    ) -> None:
        """write_results_csv raises ConfigurationError on write permission failure."""
        output_file = tmp_path / "results.csv"
        results = [
            BulkAssignResult(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                status="success",
            )
        ]

        with patch.object(Path, "open", side_effect=PermissionError("Access denied")):
            with pytest.raises(ConfigurationError) as exc_info:
                write_results_csv(results, output_file)

        assert "Cannot write to output file" in str(exc_info.value)
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_handles_empty_results_list(self, tmp_path: Path) -> None:
        """write_results_csv handles empty results list."""
        output_file = tmp_path / "results.csv"
        results: list[BulkAssignResult] = []

        write_results_csv(results, output_file)

        assert output_file.exists()
        content = output_file.read_text(encoding="utf-8")
        lines = content.strip().split("\n")

        assert len(lines) == 1  # Only header
        assert lines[0] == "row,user,phone_number,status,error"

    @pytest.mark.unit
    def test_multiple_errors_formatting(self, tmp_path: Path) -> None:
        """write_results_csv preserves semicolon-delimited error messages."""
        output_file = tmp_path / "results.csv"
        results = [
            BulkAssignResult(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                status="failed",
                error="User not found; Number not in inventory",
            )
        ]

        write_results_csv(results, output_file)

        content = output_file.read_text(encoding="utf-8")
        lines = content.strip().split("\n")

        assert "User not found; Number not in inventory" in lines[1]

    @pytest.mark.unit
    def test_field_ordering_consistent(self, tmp_path: Path) -> None:
        """write_results_csv maintains consistent field ordering."""
        output_file = tmp_path / "results.csv"
        results = [
            BulkAssignResult(
                row_number=2,
                user="user@contoso.com",
                phone_number="+14255551234",
                status="skipped",
                error="Missing required field",
            )
        ]

        write_results_csv(results, output_file)

        content = output_file.read_text(encoding="utf-8")
        lines = content.strip().split("\n")

        # Verify header order
        assert lines[0] == "row,user,phone_number,status,error"
        # Verify data order matches header
        parts = lines[1].split(",")
        assert parts[0] == "2"  # row
        assert parts[1] == "user@contoso.com"  # user
        assert parts[2] == "+14255551234"  # phone_number
        assert parts[3] == "skipped"  # status
        assert parts[4] == "Missing required field"  # error


# Sample CSV content for policy tests
VALID_POLICY_CSV = """user,policy_type,policy_name
user1@contoso.com,calling,AllowCalling
user2@contoso.com,dial-plan,US-DialPlan"""

VALID_POLICY_CSV_WITH_BOM = (
    "\ufeffuser,policy_type,policy_name\nuser1@contoso.com,calling,AllowCalling"
)

EMPTY_POLICY_FIELDS_CSV = """user,policy_type,policy_name
,calling,AllowCalling
user2@contoso.com,,US-DialPlan
user3@contoso.com,dial-plan,"""

MISSING_POLICY_USER_COLUMN_CSV = """policy_type,policy_name
calling,AllowCalling"""

MISSING_POLICY_TYPE_COLUMN_CSV = """user,policy_name
user@contoso.com,AllowCalling"""

MISSING_POLICY_NAME_COLUMN_CSV = """user,policy_type
user@contoso.com,calling"""

HEADER_ONLY_POLICY_CSV = """user,policy_type,policy_name"""

EMPTY_POLICY_FILE_CSV = ""


class TestBulkPolicyRow:
    """Tests for BulkPolicyRow dataclass."""

    @pytest.mark.unit
    def test_dataclass_creates_with_all_fields(self) -> None:
        """BulkPolicyRow stores all provided fields."""
        row = BulkPolicyRow(
            row_number=2,
            user="user@contoso.com",
            policy_type="TeamsCallingPolicy",
            policy_name="AllowCalling",
            errors=["error1"],
            warnings=["warning1"],
        )

        assert row.row_number == 2
        assert row.user == "user@contoso.com"
        assert row.policy_type == "TeamsCallingPolicy"
        assert row.policy_name == "AllowCalling"
        assert row.errors == ["error1"]
        assert row.warnings == ["warning1"]

    @pytest.mark.unit
    def test_dataclass_default_empty_lists(self) -> None:
        """BulkPolicyRow defaults errors and warnings to empty lists."""
        row = BulkPolicyRow(
            row_number=2,
            user="user@contoso.com",
            policy_type="TeamsCallingPolicy",
            policy_name="AllowCalling",
        )

        assert row.errors == []
        assert row.warnings == []

    @pytest.mark.unit
    def test_dataclass_mutable_defaults_independent(self) -> None:
        """BulkPolicyRow instances have independent error/warning lists."""
        row1 = BulkPolicyRow(
            row_number=2,
            user="user1@contoso.com",
            policy_type="TeamsCallingPolicy",
            policy_name="Policy1",
        )
        row2 = BulkPolicyRow(
            row_number=3,
            user="user2@contoso.com",
            policy_type="TenantDialPlan",
            policy_name="Policy2",
        )

        row1.errors.append("error for row1")

        assert row1.errors == ["error for row1"]
        assert row2.errors == []


class TestBulkPolicyValidationResult:
    """Tests for BulkPolicyValidationResult dataclass."""

    @pytest.mark.unit
    def test_dataclass_creates_with_all_fields(self) -> None:
        """BulkPolicyValidationResult stores all provided fields."""
        rows = [
            BulkPolicyRow(
                row_number=2,
                user="user@contoso.com",
                policy_type="TeamsCallingPolicy",
                policy_name="AllowCalling",
            )
        ]
        result = BulkPolicyValidationResult(
            rows=rows,
            valid_count=1,
            error_count=0,
            warning_count=0,
        )

        assert result.rows == rows
        assert result.valid_count == 1
        assert result.error_count == 0
        assert result.warning_count == 0


class TestParsePolicyCsv:
    """Tests for parse_policy_csv function."""

    @pytest.mark.unit
    def test_parses_valid_csv_with_all_columns(self, tmp_path: Path) -> None:
        """parse_policy_csv parses CSV with all columns correctly."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(VALID_POLICY_CSV, encoding="utf-8")

        rows = parse_policy_csv(csv_file)

        assert len(rows) == 2
        assert rows[0].row_number == 2
        assert rows[0].user == "user1@contoso.com"
        assert rows[0].policy_type == "calling"
        assert rows[0].policy_name == "AllowCalling"
        assert rows[0].errors == []
        assert rows[0].warnings == []

        assert rows[1].row_number == 3
        assert rows[1].user == "user2@contoso.com"
        assert rows[1].policy_type == "dial-plan"
        assert rows[1].policy_name == "US-DialPlan"

    @pytest.mark.unit
    def test_handles_utf8_bom(self, tmp_path: Path) -> None:
        """parse_policy_csv handles UTF-8 BOM from PowerShell exports."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(VALID_POLICY_CSV_WITH_BOM, encoding="utf-8")

        rows = parse_policy_csv(csv_file)

        assert len(rows) == 1
        assert rows[0].user == "user1@contoso.com"

    @pytest.mark.unit
    def test_collects_errors_for_empty_required_fields(self, tmp_path: Path) -> None:
        """parse_policy_csv collects errors when required fields are empty."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(EMPTY_POLICY_FIELDS_CSV, encoding="utf-8")

        rows = parse_policy_csv(csv_file)

        assert len(rows) == 3
        # Row 2: missing user
        assert "Missing required field: user" in rows[0].errors
        assert len(rows[0].errors) == 1

        # Row 3: missing policy_type
        assert "Missing required field: policy_type" in rows[1].errors
        assert len(rows[1].errors) == 1

        # Row 4: missing policy_name
        assert "Missing required field: policy_name" in rows[2].errors
        assert len(rows[2].errors) == 1

    @pytest.mark.unit
    def test_raises_validation_error_missing_user_column(self, tmp_path: Path) -> None:
        """parse_policy_csv raises ValidationError when user column missing."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(MISSING_POLICY_USER_COLUMN_CSV, encoding="utf-8")

        with pytest.raises(ValidationError) as exc_info:
            parse_policy_csv(csv_file)

        assert "user" in str(exc_info.value)
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_raises_validation_error_missing_policy_type_column(
        self, tmp_path: Path
    ) -> None:
        """parse_policy_csv raises ValidationError when policy_type column missing."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(MISSING_POLICY_TYPE_COLUMN_CSV, encoding="utf-8")

        with pytest.raises(ValidationError) as exc_info:
            parse_policy_csv(csv_file)

        assert "policy_type" in str(exc_info.value)
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_raises_validation_error_missing_policy_name_column(
        self, tmp_path: Path
    ) -> None:
        """parse_policy_csv raises ValidationError when policy_name column missing."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(MISSING_POLICY_NAME_COLUMN_CSV, encoding="utf-8")

        with pytest.raises(ValidationError) as exc_info:
            parse_policy_csv(csv_file)

        assert "policy_name" in str(exc_info.value)
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_returns_empty_list_for_header_only_csv(self, tmp_path: Path) -> None:
        """parse_policy_csv returns empty list when CSV has only header."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(HEADER_ONLY_POLICY_CSV, encoding="utf-8")

        rows = parse_policy_csv(csv_file)

        assert rows == []

    @pytest.mark.unit
    def test_raises_validation_error_for_empty_file(self, tmp_path: Path) -> None:
        """parse_policy_csv raises ValidationError for empty file."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(EMPTY_POLICY_FILE_CSV, encoding="utf-8")

        with pytest.raises(ValidationError) as exc_info:
            parse_policy_csv(csv_file)

        assert "empty" in str(exc_info.value).lower()

    @pytest.mark.unit
    def test_raises_validation_error_for_missing_file(self, tmp_path: Path) -> None:
        """parse_policy_csv raises ValidationError when file doesn't exist."""
        csv_file = tmp_path / "nonexistent.csv"

        with pytest.raises(ValidationError) as exc_info:
            parse_policy_csv(csv_file)

        assert "not found" in str(exc_info.value).lower()
        assert exc_info.value.remediation is not None

    @pytest.mark.unit
    def test_row_numbers_are_1_indexed(self, tmp_path: Path) -> None:
        """parse_policy_csv uses 1-indexed row numbers starting after header."""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(VALID_POLICY_CSV, encoding="utf-8")

        rows = parse_policy_csv(csv_file)

        # Header is row 1, data starts at row 2
        assert rows[0].row_number == 2
        assert rows[1].row_number == 3

    @pytest.mark.unit
    def test_strips_whitespace_from_values(self, tmp_path: Path) -> None:
        """parse_policy_csv strips leading/trailing whitespace from values."""
        csv_content = """user,policy_type,policy_name
  user1@contoso.com  ,  calling  ,  AllowCalling  """
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        rows = parse_policy_csv(csv_file)

        assert rows[0].user == "user1@contoso.com"
        assert rows[0].policy_type == "calling"
        assert rows[0].policy_name == "AllowCalling"


class TestBulkPolicyResult:
    """Tests for BulkPolicyResult dataclass."""

    @pytest.mark.unit
    def test_dataclass_creates_with_all_fields(self) -> None:
        """BulkPolicyResult stores all provided fields."""
        result = BulkPolicyResult(
            row_number=2,
            user="user@contoso.com",
            policy_type="TeamsCallingPolicy",
            policy_name="AllowCalling",
            status="success",
            error="Some error",
        )

        assert result.row_number == 2
        assert result.user == "user@contoso.com"
        assert result.policy_type == "TeamsCallingPolicy"
        assert result.policy_name == "AllowCalling"
        assert result.status == "success"
        assert result.error == "Some error"

    @pytest.mark.unit
    def test_dataclass_default_error_none(self) -> None:
        """BulkPolicyResult defaults error to None."""
        result = BulkPolicyResult(
            row_number=2,
            user="user@contoso.com",
            policy_type="TeamsCallingPolicy",
            policy_name="AllowCalling",
            status="success",
        )

        assert result.error is None

    @pytest.mark.unit
    def test_dataclass_accepts_different_statuses(self) -> None:
        """BulkPolicyResult accepts success, failed, and skipped statuses."""
        success = BulkPolicyResult(
            row_number=2,
            user="user1@contoso.com",
            policy_type="TeamsCallingPolicy",
            policy_name="Policy1",
            status="success",
        )
        failed = BulkPolicyResult(
            row_number=3,
            user="user2@contoso.com",
            policy_type="TenantDialPlan",
            policy_name="Policy2",
            status="failed",
            error="Policy not found",
        )
        skipped = BulkPolicyResult(
            row_number=4,
            user="user3@contoso.com",
            policy_type="TeamsEmergencyCallingPolicy",
            policy_name="Policy3",
            status="skipped",
            error="Missing required field",
        )

        assert success.status == "success"
        assert failed.status == "failed"
        assert skipped.status == "skipped"


class TestWritePolicyResultsCsv:
    """Tests for write_policy_results_csv function."""

    @pytest.mark.unit
    def test_writes_valid_results_file(self, tmp_path: Path) -> None:
        """write_policy_results_csv creates a valid CSV file with results."""
        output_file = tmp_path / "results.csv"
        results = [
            BulkPolicyResult(
                row_number=2,
                user="user1@contoso.com",
                policy_type="TeamsCallingPolicy",
                policy_name="AllowCalling",
                status="success",
            ),
            BulkPolicyResult(
                row_number=3,
                user="user2@contoso.com",
                policy_type="TenantDialPlan",
                policy_name="US-DialPlan",
                status="failed",
                error="Policy not found",
            ),
        ]

        write_policy_results_csv(results, output_file)

        assert output_file.exists()
        content = output_file.read_text(encoding="utf-8")
        lines = content.strip().split("\n")

        assert len(lines) == 3  # Header + 2 data rows
        assert lines[0] == "row,user,policy_type,policy_name,status,error"
        assert (
            lines[1] == "2,user1@contoso.com,TeamsCallingPolicy,AllowCalling,success,"
        )
        assert (
            lines[2]
            == "3,user2@contoso.com,TenantDialPlan,US-DialPlan,failed,Policy not found"
        )

    @pytest.mark.unit
    def test_writes_utf8_without_bom(self, tmp_path: Path) -> None:
        """write_policy_results_csv writes UTF-8 without BOM."""
        output_file = tmp_path / "results.csv"
        results = [
            BulkPolicyResult(
                row_number=2,
                user="user@contoso.com",
                policy_type="TeamsCallingPolicy",
                policy_name="AllowCalling",
                status="success",
            )
        ]

        write_policy_results_csv(results, output_file)

        # Read raw bytes and check for BOM
        raw_content = output_file.read_bytes()
        assert not raw_content.startswith(b"\xef\xbb\xbf")  # UTF-8 BOM
        assert raw_content.startswith(b"row,")

    @pytest.mark.unit
    def test_creates_parent_directory(self, tmp_path: Path) -> None:
        """write_policy_results_csv creates parent directories if they don't exist."""
        output_file = tmp_path / "nested" / "output" / "results.csv"
        results = [
            BulkPolicyResult(
                row_number=2,
                user="user@contoso.com",
                policy_type="TeamsCallingPolicy",
                policy_name="AllowCalling",
                status="success",
            )
        ]

        write_policy_results_csv(results, output_file)

        assert output_file.exists()
        assert output_file.parent.exists()

    @pytest.mark.unit
    def test_handles_empty_results_list(self, tmp_path: Path) -> None:
        """write_policy_results_csv handles empty results list."""
        output_file = tmp_path / "results.csv"
        results: list[BulkPolicyResult] = []

        write_policy_results_csv(results, output_file)

        assert output_file.exists()
        content = output_file.read_text(encoding="utf-8")
        lines = content.strip().split("\n")

        assert len(lines) == 1  # Only header
        assert lines[0] == "row,user,policy_type,policy_name,status,error"
