"""Unit tests for EnvUpdateExecutor."""

import os
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

# Patch the logger before importing the executor
with patch("app.utils.logger.get_logger", return_value=MagicMock()):
    from app.executors.env_update_executor import EnvUpdateExecutor


class TestEnvUpdateExecutor:
    """Test suite for EnvUpdateExecutor."""

    @pytest.fixture
    def executor(self):
        """Create executor instance with mocked logger."""
        with patch("app.executors.base.get_logger", return_value=MagicMock()):
            return EnvUpdateExecutor()

    @pytest.fixture
    def temp_env_root(self, tmp_path):
        """Create a temporary directory to act as /opt/letsbe/env."""
        env_dir = tmp_path / "opt" / "letsbe" / "env"
        env_dir.mkdir(parents=True)
        return env_dir

    @pytest.fixture
    def mock_settings(self, temp_env_root):
        """Mock settings with temporary env root."""
        settings = MagicMock()
        settings.allowed_env_root = str(temp_env_root)
        return settings

    # ==================== New File Creation Tests ====================

    @pytest.mark.asyncio
    async def test_create_new_env_file(self, executor, temp_env_root, mock_settings):
        """Test creating a new ENV file when it doesn't exist."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "newapp.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {
                    "DATABASE_URL": "postgres://localhost/mydb",
                    "API_KEY": "secret123",
                },
            })

            assert result.success is True
            assert set(result.data["updated_keys"]) == {"DATABASE_URL", "API_KEY"}
            assert result.data["removed_keys"] == []
            assert result.data["path"] == str(env_path)

            # Verify file was created
            assert env_path.exists()
            content = env_path.read_text()
            assert "API_KEY=secret123" in content
            assert "DATABASE_URL=postgres://localhost/mydb" in content

    @pytest.mark.asyncio
    async def test_create_env_file_in_nested_directory(self, executor, temp_env_root, mock_settings):
        """Test creating ENV file in a nested directory that doesn't exist."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "subdir" / "app.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"KEY": "value"},
            })

            assert result.success is True
            assert env_path.exists()

    # ==================== Update Existing Keys Tests ====================

    @pytest.mark.asyncio
    async def test_update_existing_keys(self, executor, temp_env_root, mock_settings):
        """Test updating existing keys in an ENV file."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("EXISTING_KEY=old_value\nANOTHER_KEY=keep_this\n")

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"EXISTING_KEY": "new_value"},
            })

            assert result.success is True
            assert "EXISTING_KEY" in result.data["updated_keys"]

            content = env_path.read_text()
            assert "EXISTING_KEY=new_value" in content
            assert "ANOTHER_KEY=keep_this" in content

    @pytest.mark.asyncio
    async def test_add_new_keys_to_existing_file(self, executor, temp_env_root, mock_settings):
        """Test adding new keys to an existing ENV file."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("EXISTING_KEY=value\n")

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"NEW_KEY": "new_value"},
            })

            assert result.success is True
            content = env_path.read_text()
            assert "EXISTING_KEY=value" in content
            assert "NEW_KEY=new_value" in content

    @pytest.mark.asyncio
    async def test_preserves_key_values(self, executor, temp_env_root, mock_settings):
        """Test that existing key values are preserved when not updated."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("KEY1=value1\nKEY2=value2\n")

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"KEY1": "updated"},
            })

            assert result.success is True
            content = env_path.read_text()
            assert "KEY1=updated" in content
            assert "KEY2=value2" in content

    # ==================== Remove Keys Tests ====================

    @pytest.mark.asyncio
    async def test_remove_existing_keys(self, executor, temp_env_root, mock_settings):
        """Test removing existing keys from an ENV file."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("KEEP_KEY=value\nREMOVE_KEY=to_remove\nANOTHER_KEEP=keep\n")

            result = await executor.execute({
                "path": str(env_path),
                "remove_keys": ["REMOVE_KEY"],
            })

            assert result.success is True
            assert result.data["removed_keys"] == ["REMOVE_KEY"]
            assert result.data["updated_keys"] == []

            content = env_path.read_text()
            assert "REMOVE_KEY" not in content
            assert "KEEP_KEY=value" in content
            assert "ANOTHER_KEEP=keep" in content

    @pytest.mark.asyncio
    async def test_remove_nonexistent_key(self, executor, temp_env_root, mock_settings):
        """Test removing a key that doesn't exist (should succeed but not report as removed)."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("EXISTING_KEY=value\n")

            result = await executor.execute({
                "path": str(env_path),
                "remove_keys": ["NONEXISTENT_KEY"],
            })

            assert result.success is True
            assert result.data["removed_keys"] == []  # Not reported as removed since it didn't exist

    @pytest.mark.asyncio
    async def test_update_and_remove_together(self, executor, temp_env_root, mock_settings):
        """Test updating and removing keys in the same operation."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("KEY1=old\nKEY2=remove_me\nKEY3=keep\n")

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"KEY1": "new", "NEW_KEY": "added"},
                "remove_keys": ["KEY2"],
            })

            assert result.success is True
            assert "KEY1" in result.data["updated_keys"]
            assert "NEW_KEY" in result.data["updated_keys"]
            assert result.data["removed_keys"] == ["KEY2"]

            content = env_path.read_text()
            assert "KEY1=new" in content
            assert "NEW_KEY=added" in content
            assert "KEY3=keep" in content
            assert "KEY2" not in content

    @pytest.mark.asyncio
    async def test_remove_multiple_keys(self, executor, temp_env_root, mock_settings):
        """Test removing multiple keys at once."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("A=1\nB=2\nC=3\nD=4\n")

            result = await executor.execute({
                "path": str(env_path),
                "remove_keys": ["A", "C"],
            })

            assert result.success is True
            assert set(result.data["removed_keys"]) == {"A", "C"}

            content = env_path.read_text()
            assert "A=" not in content
            assert "C=" not in content
            assert "B=2" in content
            assert "D=4" in content

    # ==================== Invalid Key Name Tests ====================

    @pytest.mark.asyncio
    async def test_reject_invalid_update_key_lowercase(self, executor, temp_env_root, mock_settings):
        """Test rejection of lowercase keys in updates."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"invalid_key": "value"},
            })

            assert result.success is False
            assert "Invalid ENV key format" in result.error

    @pytest.mark.asyncio
    async def test_reject_invalid_update_key_starts_with_number(self, executor, temp_env_root, mock_settings):
        """Test rejection of keys starting with a number."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"1INVALID": "value"},
            })

            assert result.success is False
            assert "Invalid ENV key format" in result.error

    @pytest.mark.asyncio
    async def test_reject_invalid_update_key_special_chars(self, executor, temp_env_root, mock_settings):
        """Test rejection of keys with special characters."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"INVALID-KEY": "value"},
            })

            assert result.success is False
            assert "Invalid ENV key format" in result.error

    @pytest.mark.asyncio
    async def test_reject_invalid_remove_key(self, executor, temp_env_root, mock_settings):
        """Test rejection of invalid keys in remove_keys."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("VALID_KEY=value\n")

            result = await executor.execute({
                "path": str(env_path),
                "remove_keys": ["invalid_lowercase"],
            })

            assert result.success is False
            assert "Invalid ENV key format" in result.error

    @pytest.mark.asyncio
    async def test_accept_valid_key_formats(self, executor, temp_env_root, mock_settings):
        """Test acceptance of various valid key formats."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"

            valid_keys = {
                "A": "1",
                "AB": "2",
                "A1": "3",
                "A_B": "4",
                "ABC123_XYZ": "5",
                "DATABASE_URL": "postgres://localhost/db",
            }

            result = await executor.execute({
                "path": str(env_path),
                "updates": valid_keys,
            })

            assert result.success is True
            assert set(result.data["updated_keys"]) == set(valid_keys.keys())

    # ==================== Path Validation Tests ====================

    @pytest.mark.asyncio
    async def test_reject_path_outside_allowed_root(self, executor, temp_env_root, mock_settings):
        """Test rejection of paths outside /opt/letsbe/env."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            # Try to write to parent directory
            result = await executor.execute({
                "path": "/etc/passwd",
                "updates": {"HACK": "attempt"},
            })

            assert result.success is False
            assert "Path validation failed" in result.error

    @pytest.mark.asyncio
    async def test_reject_path_traversal_attack(self, executor, temp_env_root, mock_settings):
        """Test rejection of directory traversal attempts."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            result = await executor.execute({
                "path": str(temp_env_root / ".." / ".." / "etc" / "passwd"),
                "updates": {"HACK": "attempt"},
            })

            assert result.success is False
            assert "Path validation failed" in result.error or "traversal" in result.error.lower()

    @pytest.mark.asyncio
    async def test_accept_valid_path_in_allowed_root(self, executor, temp_env_root, mock_settings):
        """Test acceptance of valid paths within allowed root."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "valid" / "path" / "app.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"VALID": "path"},
            })

            assert result.success is True

    # ==================== Payload Validation Tests ====================

    @pytest.mark.asyncio
    async def test_reject_missing_path(self, executor):
        """Test rejection of payload without path."""
        with pytest.raises(ValueError, match="Missing required field: path"):
            await executor.execute({
                "updates": {"KEY": "value"},
            })

    @pytest.mark.asyncio
    async def test_reject_empty_operations(self, executor, temp_env_root, mock_settings):
        """Test rejection when neither updates nor remove_keys provided."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            result = await executor.execute({
                "path": str(temp_env_root / "app.env"),
            })

            assert result.success is False
            assert "At least one of 'updates' or 'remove_keys'" in result.error

    @pytest.mark.asyncio
    async def test_reject_invalid_updates_type(self, executor, temp_env_root, mock_settings):
        """Test rejection when updates is not a dict."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            result = await executor.execute({
                "path": str(temp_env_root / "app.env"),
                "updates": ["not", "a", "dict"],
            })

            assert result.success is False
            assert "'updates' must be a dictionary" in result.error

    @pytest.mark.asyncio
    async def test_reject_invalid_remove_keys_type(self, executor, temp_env_root, mock_settings):
        """Test rejection when remove_keys is not a list."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            result = await executor.execute({
                "path": str(temp_env_root / "app.env"),
                "remove_keys": {"not": "a_list"},
            })

            assert result.success is False
            assert "'remove_keys' must be a list" in result.error

    # ==================== File Permission Tests ====================

    @pytest.mark.asyncio
    @pytest.mark.skipif(os.name == "nt", reason="chmod not fully supported on Windows")
    async def test_file_permissions_640(self, executor, temp_env_root, mock_settings):
        """Test that created files have 640 permissions."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "secure.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"SECRET": "value"},
            })

            assert result.success is True

            # Check file permissions
            file_stat = env_path.stat()
            # 0o640 = owner rw, group r, others none
            expected_mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP
            actual_mode = stat.S_IMODE(file_stat.st_mode)
            assert actual_mode == expected_mode, f"Expected {oct(expected_mode)}, got {oct(actual_mode)}"

    # ==================== ENV File Parsing Tests ====================

    @pytest.mark.asyncio
    async def test_parse_quoted_values(self, executor, temp_env_root, mock_settings):
        """Test parsing of quoted values in ENV files."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text('QUOTED="value with spaces"\nSINGLE=\'single quoted\'\n')

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"NEW": "added"},
            })

            assert result.success is True
            content = env_path.read_text()
            # Values should be preserved (without extra quotes in the parsed form)
            assert "NEW=added" in content

    @pytest.mark.asyncio
    async def test_handle_values_with_equals(self, executor, temp_env_root, mock_settings):
        """Test handling of values containing equals signs."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"URL": "postgres://user:pass@host/db?opt=val"},
            })

            assert result.success is True
            content = env_path.read_text()
            # Values with = should be quoted
            assert 'URL="postgres://user:pass@host/db?opt=val"' in content

    @pytest.mark.asyncio
    async def test_keys_sorted_in_output(self, executor, temp_env_root, mock_settings):
        """Test that keys are sorted alphabetically in output."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {
                    "ZEBRA": "last",
                    "APPLE": "first",
                    "MANGO": "middle",
                },
            })

            assert result.success is True
            content = env_path.read_text()
            lines = [l for l in content.splitlines() if l]
            keys = [l.split("=")[0] for l in lines]
            assert keys == sorted(keys)

    # ==================== Edge Cases ====================

    @pytest.mark.asyncio
    async def test_empty_env_file(self, executor, temp_env_root, mock_settings):
        """Test handling of empty existing ENV file."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "empty.env"
            env_path.write_text("")

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"NEW_KEY": "value"},
            })

            assert result.success is True
            content = env_path.read_text()
            assert "NEW_KEY=value" in content

    @pytest.mark.asyncio
    async def test_remove_all_keys(self, executor, temp_env_root, mock_settings):
        """Test removing all keys results in empty file."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"
            env_path.write_text("ONLY_KEY=value\n")

            result = await executor.execute({
                "path": str(env_path),
                "remove_keys": ["ONLY_KEY"],
            })

            assert result.success is True
            content = env_path.read_text()
            assert content == ""

    @pytest.mark.asyncio
    async def test_value_with_newline(self, executor, temp_env_root, mock_settings):
        """Test handling values with newlines (should be quoted)."""
        with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
            env_path = temp_env_root / "app.env"

            result = await executor.execute({
                "path": str(env_path),
                "updates": {"MULTILINE": "line1\nline2"},
            })

            assert result.success is True
            content = env_path.read_text()
            assert 'MULTILINE="line1\nline2"' in content


class TestEnvUpdateExecutorInternal:
    """Tests for internal methods of EnvUpdateExecutor."""

    @pytest.fixture
    def executor(self):
        """Create executor instance with mocked logger."""
        with patch("app.executors.base.get_logger", return_value=MagicMock()):
            return EnvUpdateExecutor()

    def test_parse_env_file_basic(self, executor):
        """Test basic ENV file parsing."""
        content = "KEY1=value1\nKEY2=value2\n"
        result = executor._parse_env_file(content)
        assert result == {"KEY1": "value1", "KEY2": "value2"}

    def test_parse_env_file_with_comments(self, executor):
        """Test parsing ignores comments."""
        content = "# Comment\nKEY=value\n# Another comment\n"
        result = executor._parse_env_file(content)
        assert result == {"KEY": "value"}

    def test_parse_env_file_with_empty_lines(self, executor):
        """Test parsing ignores empty lines."""
        content = "KEY1=value1\n\n\nKEY2=value2\n"
        result = executor._parse_env_file(content)
        assert result == {"KEY1": "value1", "KEY2": "value2"}

    def test_parse_env_file_with_quotes(self, executor):
        """Test parsing handles quoted values."""
        content = 'KEY1="quoted value"\nKEY2=\'single quoted\'\n'
        result = executor._parse_env_file(content)
        assert result == {"KEY1": "quoted value", "KEY2": "single quoted"}

    def test_parse_env_file_with_equals_in_value(self, executor):
        """Test parsing handles equals signs in values."""
        content = "URL=postgres://user:pass@host/db?opt=val\n"
        result = executor._parse_env_file(content)
        assert result == {"URL": "postgres://user:pass@host/db?opt=val"}

    def test_serialize_env_basic(self, executor):
        """Test basic ENV serialization."""
        env_dict = {"KEY1": "value1", "KEY2": "value2"}
        result = executor._serialize_env(env_dict)
        assert "KEY1=value1" in result
        assert "KEY2=value2" in result

    def test_serialize_env_sorted(self, executor):
        """Test serialization produces sorted output."""
        env_dict = {"ZEBRA": "z", "APPLE": "a"}
        result = executor._serialize_env(env_dict)
        lines = result.strip().split("\n")
        assert lines[0].startswith("APPLE=")
        assert lines[1].startswith("ZEBRA=")

    def test_serialize_env_quotes_special_values(self, executor):
        """Test serialization quotes values with special characters."""
        env_dict = {
            "SPACES": "has spaces",
            "EQUALS": "has=equals",
            "NEWLINE": "has\nnewline",
        }
        result = executor._serialize_env(env_dict)
        assert 'SPACES="has spaces"' in result
        assert 'EQUALS="has=equals"' in result
        assert 'NEWLINE="has\nnewline"' in result

    def test_serialize_env_empty_dict(self, executor):
        """Test serialization of empty dict."""
        result = executor._serialize_env({})
        assert result == ""
