# -*- coding: utf-8 -*-
"""Unit tests for resource_cache module.

Tests cache read/write functionality, fallback mechanism, and distinct values API.
"""

import pytest
from unittest.mock import patch, MagicMock
import pandas as pd
import json


class TestGetDistinctValues:
    """Test get_distinct_values function."""

    @pytest.fixture(autouse=True)
    def reset_modules(self):
        """Reset module state before each test."""
        import mes_dashboard.core.redis_client as rc
        rc._REDIS_CLIENT = None
        yield
        rc._REDIS_CLIENT = None

    def test_returns_sorted_unique_values(self):
        """Test returns sorted unique values from resources."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'WORKCENTERNAME': 'Station_B', 'RESOURCEFAMILYNAME': 'Family1'},
            {'WORKCENTERNAME': 'Station_A', 'RESOURCEFAMILYNAME': 'Family2'},
            {'WORKCENTERNAME': 'Station_B', 'RESOURCEFAMILYNAME': 'Family1'},  # duplicate
            {'WORKCENTERNAME': 'Station_C', 'RESOURCEFAMILYNAME': None},  # None value
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_distinct_values('WORKCENTERNAME')

            assert result == ['Station_A', 'Station_B', 'Station_C']

    def test_excludes_none_and_empty_strings(self):
        """Test excludes None and empty string values."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEFAMILYNAME': 'Family1'},
            {'RESOURCEFAMILYNAME': None},
            {'RESOURCEFAMILYNAME': ''},
            {'RESOURCEFAMILYNAME': 'Family2'},
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_distinct_values('RESOURCEFAMILYNAME')

            assert result == ['Family1', 'Family2']

    def test_handles_nan_values(self):
        """Test handles NaN values (pandas float NaN)."""
        import mes_dashboard.services.resource_cache as rc
        import numpy as np

        mock_resources = [
            {'WORKCENTERNAME': 'Station_A'},
            {'WORKCENTERNAME': float('nan')},  # NaN
            {'WORKCENTERNAME': np.nan},  # NumPy NaN
            {'WORKCENTERNAME': 'Station_B'},
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_distinct_values('WORKCENTERNAME')

            assert result == ['Station_A', 'Station_B']

    def test_handles_mixed_types(self):
        """Test handles mixed types (converts to string)."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'PJ_DEPARTMENT': 'Dept_A'},
            {'PJ_DEPARTMENT': 123},  # int
            {'PJ_DEPARTMENT': 'Dept_B'},
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_distinct_values('PJ_DEPARTMENT')

            assert '123' in result
            assert 'Dept_A' in result
            assert 'Dept_B' in result

    def test_returns_empty_list_when_no_resources(self):
        """Test returns empty list when no resources."""
        import mes_dashboard.services.resource_cache as rc

        with patch.object(rc, 'get_all_resources', return_value=[]):
            result = rc.get_distinct_values('WORKCENTERNAME')

            assert result == []


class TestConvenienceMethods:
    """Test convenience methods for common columns."""

    def test_get_resource_families_calls_get_distinct_values(self):
        """Test get_resource_families calls get_distinct_values with correct column."""
        import mes_dashboard.services.resource_cache as rc

        with patch.object(rc, 'get_distinct_values', return_value=['Family1', 'Family2']) as mock:
            result = rc.get_resource_families()

            mock.assert_called_once_with('RESOURCEFAMILYNAME')
            assert result == ['Family1', 'Family2']

    def test_get_workcenters_calls_get_distinct_values(self):
        """Test get_workcenters calls get_distinct_values with correct column."""
        import mes_dashboard.services.resource_cache as rc

        with patch.object(rc, 'get_distinct_values', return_value=['WC1', 'WC2']) as mock:
            result = rc.get_workcenters()

            mock.assert_called_once_with('WORKCENTERNAME')
            assert result == ['WC1', 'WC2']

    def test_get_departments_calls_get_distinct_values(self):
        """Test get_departments calls get_distinct_values with correct column."""
        import mes_dashboard.services.resource_cache as rc

        with patch.object(rc, 'get_distinct_values', return_value=['Dept1', 'Dept2']) as mock:
            result = rc.get_departments()

            mock.assert_called_once_with('PJ_DEPARTMENT')
            assert result == ['Dept1', 'Dept2']


class TestGetAllResources:
    """Test get_all_resources function."""

    @pytest.fixture(autouse=True)
    def reset_modules(self):
        """Reset module state before each test."""
        import mes_dashboard.core.redis_client as rc
        rc._REDIS_CLIENT = None
        yield
        rc._REDIS_CLIENT = None

    def test_returns_cached_data_when_available(self):
        """Test returns cached data from Redis when available."""
        import mes_dashboard.services.resource_cache as rc

        test_data = [
            {'RESOURCEID': 'R001', 'RESOURCENAME': 'Machine1'},
            {'RESOURCEID': 'R002', 'RESOURCENAME': 'Machine2'}
        ]
        cached_json = json.dumps(test_data)

        mock_client = MagicMock()
        mock_client.get.return_value = cached_json

        with patch.object(rc, 'REDIS_ENABLED', True):
            with patch.object(rc, 'RESOURCE_CACHE_ENABLED', True):
                with patch.object(rc, 'get_redis_client', return_value=mock_client):
                    result = rc.get_all_resources()

                    assert len(result) == 2
                    assert result[0]['RESOURCEID'] == 'R001'

    def test_falls_back_to_oracle_when_cache_miss(self):
        """Test falls back to Oracle when cache is empty."""
        import mes_dashboard.services.resource_cache as rc

        mock_client = MagicMock()
        mock_client.get.return_value = None

        oracle_df = pd.DataFrame({
            'RESOURCEID': ['R001'],
            'RESOURCENAME': ['Machine1']
        })

        with patch.object(rc, 'REDIS_ENABLED', True):
            with patch.object(rc, 'RESOURCE_CACHE_ENABLED', True):
                with patch.object(rc, 'get_redis_client', return_value=mock_client):
                    with patch.object(rc, '_load_from_oracle', return_value=oracle_df):
                        result = rc.get_all_resources()

                        assert len(result) == 1
                        assert result[0]['RESOURCEID'] == 'R001'

    def test_returns_empty_when_both_unavailable(self):
        """Test returns empty list when both cache and Oracle fail."""
        import mes_dashboard.services.resource_cache as rc

        mock_client = MagicMock()
        mock_client.get.return_value = None

        with patch.object(rc, 'REDIS_ENABLED', True):
            with patch.object(rc, 'RESOURCE_CACHE_ENABLED', True):
                with patch.object(rc, 'get_redis_client', return_value=mock_client):
                    with patch.object(rc, '_load_from_oracle', return_value=None):
                        result = rc.get_all_resources()

                        assert result == []


class TestGetResourceById:
    """Test get_resource_by_id function."""

    def test_returns_matching_resource(self):
        """Test returns resource with matching ID."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEID': 'R001', 'RESOURCENAME': 'Machine1'},
            {'RESOURCEID': 'R002', 'RESOURCENAME': 'Machine2'}
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_resource_by_id('R002')

            assert result is not None
            assert result['RESOURCEID'] == 'R002'
            assert result['RESOURCENAME'] == 'Machine2'

    def test_returns_none_when_not_found(self):
        """Test returns None when ID not found."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEID': 'R001', 'RESOURCENAME': 'Machine1'}
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_resource_by_id('R999')

            assert result is None


class TestGetResourcesByIds:
    """Test get_resources_by_ids function."""

    def test_returns_matching_resources(self):
        """Test returns all resources with matching IDs."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEID': 'R001', 'RESOURCENAME': 'Machine1'},
            {'RESOURCEID': 'R002', 'RESOURCENAME': 'Machine2'},
            {'RESOURCEID': 'R003', 'RESOURCENAME': 'Machine3'}
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_resources_by_ids(['R001', 'R003'])

            assert len(result) == 2
            ids = [r['RESOURCEID'] for r in result]
            assert 'R001' in ids
            assert 'R003' in ids

    def test_ignores_missing_ids(self):
        """Test ignores IDs that don't exist."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEID': 'R001', 'RESOURCENAME': 'Machine1'}
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_resources_by_ids(['R001', 'R999'])

            assert len(result) == 1
            assert result[0]['RESOURCEID'] == 'R001'


class TestGetResourcesByFilter:
    """Test get_resources_by_filter function."""

    def test_filters_by_workcenter(self):
        """Test filters resources by workcenter."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEID': 'R001', 'WORKCENTERNAME': 'WC1'},
            {'RESOURCEID': 'R002', 'WORKCENTERNAME': 'WC2'},
            {'RESOURCEID': 'R003', 'WORKCENTERNAME': 'WC1'}
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_resources_by_filter(workcenters=['WC1'])

            assert len(result) == 2

    def test_filters_by_family(self):
        """Test filters resources by family."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEID': 'R001', 'RESOURCEFAMILYNAME': 'F1'},
            {'RESOURCEID': 'R002', 'RESOURCEFAMILYNAME': 'F2'}
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_resources_by_filter(families=['F1'])

            assert len(result) == 1
            assert result[0]['RESOURCEFAMILYNAME'] == 'F1'

    def test_filters_by_production_flag(self):
        """Test filters resources by production flag."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEID': 'R001', 'PJ_ISPRODUCTION': 1},
            {'RESOURCEID': 'R002', 'PJ_ISPRODUCTION': 0},
            {'RESOURCEID': 'R003', 'PJ_ISPRODUCTION': 1}
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_resources_by_filter(is_production=True)

            assert len(result) == 2

    def test_combines_multiple_filters(self):
        """Test combines multiple filter criteria."""
        import mes_dashboard.services.resource_cache as rc

        mock_resources = [
            {'RESOURCEID': 'R001', 'WORKCENTERNAME': 'WC1', 'RESOURCEFAMILYNAME': 'F1'},
            {'RESOURCEID': 'R002', 'WORKCENTERNAME': 'WC1', 'RESOURCEFAMILYNAME': 'F2'},
            {'RESOURCEID': 'R003', 'WORKCENTERNAME': 'WC2', 'RESOURCEFAMILYNAME': 'F1'}
        ]

        with patch.object(rc, 'get_all_resources', return_value=mock_resources):
            result = rc.get_resources_by_filter(workcenters=['WC1'], families=['F1'])

            assert len(result) == 1
            assert result[0]['RESOURCEID'] == 'R001'


class TestGetCacheStatus:
    """Test get_cache_status function."""

    @pytest.fixture(autouse=True)
    def reset_modules(self):
        """Reset module state before each test."""
        import mes_dashboard.core.redis_client as rc
        rc._REDIS_CLIENT = None
        yield
        rc._REDIS_CLIENT = None

    def test_returns_disabled_when_cache_disabled(self):
        """Test returns disabled status when cache is disabled."""
        import mes_dashboard.services.resource_cache as rc

        with patch.object(rc, 'REDIS_ENABLED', False):
            result = rc.get_cache_status()

            assert result['enabled'] is False
            assert result['loaded'] is False

    def test_returns_loaded_status_when_data_exists(self):
        """Test returns loaded status when cache has data."""
        import mes_dashboard.services.resource_cache as rc

        mock_client = MagicMock()
        mock_client.exists.return_value = 1
        mock_client.get.side_effect = lambda key: {
            'mes_wip:resource:meta:count': '1000',
            'mes_wip:resource:meta:version': '2024-01-15T10:00:00',
            'mes_wip:resource:meta:updated': '2024-01-15T10:30:00',
        }.get(key)

        with patch.object(rc, 'REDIS_ENABLED', True):
            with patch.object(rc, 'RESOURCE_CACHE_ENABLED', True):
                with patch.object(rc, 'get_redis_client', return_value=mock_client):
                    result = rc.get_cache_status()

                    assert result['enabled'] is True
                    assert result['loaded'] is True


class TestRefreshCache:
    """Test refresh_cache function."""

    @pytest.fixture(autouse=True)
    def reset_modules(self):
        """Reset module state before each test."""
        import mes_dashboard.core.redis_client as rc
        rc._REDIS_CLIENT = None
        yield
        rc._REDIS_CLIENT = None

    def test_returns_false_when_disabled(self):
        """Test returns False when cache is disabled."""
        import mes_dashboard.services.resource_cache as rc

        with patch.object(rc, 'REDIS_ENABLED', False):
            result = rc.refresh_cache()

            assert result is False

    def test_skips_sync_when_version_unchanged(self):
        """Test skips sync when Oracle version matches Redis version."""
        import mes_dashboard.services.resource_cache as rc

        mock_client = MagicMock()
        mock_client.get.return_value = '2024-01-15T10:00:00'
        mock_client.ping.return_value = True

        with patch.object(rc, 'REDIS_ENABLED', True):
            with patch.object(rc, 'RESOURCE_CACHE_ENABLED', True):
                with patch.object(rc, 'redis_available', return_value=True):
                    with patch.object(rc, '_get_version_from_oracle', return_value='2024-01-15T10:00:00'):
                        with patch.object(rc, '_get_version_from_redis', return_value='2024-01-15T10:00:00'):
                            result = rc.refresh_cache(force=False)

                            assert result is False

    def test_syncs_when_version_changed(self):
        """Test syncs when Oracle version differs from Redis version."""
        import mes_dashboard.services.resource_cache as rc

        mock_df = pd.DataFrame({
            'RESOURCEID': ['R001'],
            'RESOURCENAME': ['Machine1']
        })

        with patch.object(rc, 'REDIS_ENABLED', True):
            with patch.object(rc, 'RESOURCE_CACHE_ENABLED', True):
                with patch.object(rc, 'redis_available', return_value=True):
                    with patch.object(rc, '_get_version_from_oracle', return_value='2024-01-15T11:00:00'):
                        with patch.object(rc, '_get_version_from_redis', return_value='2024-01-15T10:00:00'):
                            with patch.object(rc, '_load_from_oracle', return_value=mock_df):
                                with patch.object(rc, '_sync_to_redis', return_value=True) as mock_sync:
                                    result = rc.refresh_cache(force=False)

                                    assert result is True
                                    mock_sync.assert_called_once()

    def test_force_sync_ignores_version(self):
        """Test force sync ignores version comparison."""
        import mes_dashboard.services.resource_cache as rc

        mock_df = pd.DataFrame({
            'RESOURCEID': ['R001'],
            'RESOURCENAME': ['Machine1']
        })

        with patch.object(rc, 'REDIS_ENABLED', True):
            with patch.object(rc, 'RESOURCE_CACHE_ENABLED', True):
                with patch.object(rc, 'redis_available', return_value=True):
                    with patch.object(rc, '_get_version_from_oracle', return_value='2024-01-15T10:00:00'):
                        with patch.object(rc, '_get_version_from_redis', return_value='2024-01-15T10:00:00'):
                            with patch.object(rc, '_load_from_oracle', return_value=mock_df):
                                with patch.object(rc, '_sync_to_redis', return_value=True) as mock_sync:
                                    result = rc.refresh_cache(force=True)

                                    assert result is True
                                    mock_sync.assert_called_once()


class TestBuildFilterBuilder:
    """Test _build_filter_builder function."""

    def test_includes_equipment_type_filter(self):
        """Test includes equipment type filter."""
        import mes_dashboard.services.resource_cache as rc

        builder = rc._build_filter_builder()
        builder.base_sql = "SELECT * FROM DWH.DW_MES_RESOURCE {{ WHERE_CLAUSE }}"
        sql, params = builder.build()

        assert 'OBJECTCATEGORY' in sql
        assert 'ASSEMBLY' in sql or 'WAFERSORT' in sql

    def test_includes_location_filter(self):
        """Test includes location exclusion filter with parameterization."""
        import mes_dashboard.services.resource_cache as rc

        builder = rc._build_filter_builder()
        builder.base_sql = "SELECT * FROM DWH.DW_MES_RESOURCE {{ WHERE_CLAUSE }}"
        sql, params = builder.build()

        # Check SQL contains LOCATIONNAME condition
        assert 'LOCATIONNAME' in sql
        # Parameterized query should have bind variables
        assert len(params) > 0

    def test_includes_asset_status_filter(self):
        """Test includes asset status exclusion filter with parameterization."""
        import mes_dashboard.services.resource_cache as rc

        builder = rc._build_filter_builder()
        builder.base_sql = "SELECT * FROM DWH.DW_MES_RESOURCE {{ WHERE_CLAUSE }}"
        sql, params = builder.build()

        # Check SQL contains PJ_ASSETSSTATUS condition
        assert 'PJ_ASSETSSTATUS' in sql
        # Parameterized query should have bind variables
        assert len(params) > 0

    def test_resource_load_uses_shared_sql_fragment_template(self):
        """Test resource load path uses shared SQL fragment template."""
        import mes_dashboard.services.resource_cache as rc
        from mes_dashboard.services.sql_fragments import RESOURCE_TABLE

        with patch.object(rc, "read_sql_df", return_value=pd.DataFrame()) as mock_read:
            rc._load_from_oracle()

        sql = mock_read.call_args[0][0]
        assert RESOURCE_TABLE in sql

    def test_resource_version_sql_replaces_where_clause_placeholder(self):
        """Version SQL should not leak placeholder token into Oracle."""
        import mes_dashboard.services.resource_cache as rc
        from mes_dashboard.services.sql_fragments import RESOURCE_TABLE

        with patch.object(
            rc,
            "read_sql_df",
            return_value=pd.DataFrame([{"VERSION": "2026-02-09T12:00:00"}]),
        ) as mock_read:
            rc._get_version_from_oracle()

        sql = mock_read.call_args[0][0]
        assert RESOURCE_TABLE in sql
        assert "{{ WHERE_CLAUSE }}" not in sql
        assert "{ WHERE_CLAUSE }" not in sql
        assert "WHERE " in sql


class TestResourceDerivedIndex:
    """Test derived resource index and telemetry behavior."""

    @pytest.fixture(autouse=True)
    def reset_state(self):
        import mes_dashboard.services.resource_cache as rc
        rc._resource_index = rc._new_empty_index()
        rc._resource_df_cache.invalidate("resource_data")
        yield
        rc._resource_index = rc._new_empty_index()
        rc._resource_df_cache.invalidate("resource_data")

    def test_get_resource_by_id_uses_index_snapshot(self):
        import mes_dashboard.services.resource_cache as rc

        cache_df = pd.DataFrame([{"RESOURCEID": "R001", "RESOURCENAME": "Machine1"}])
        rc._resource_df_cache.set(rc.RESOURCE_DF_CACHE_KEY, cache_df)
        snapshot = {
            "ready": True,
            "all_positions": [0],
            "by_resource_id": {"R001": 0},
        }
        with patch.object(rc, "get_resource_index_snapshot", return_value=snapshot):
            row = rc.get_resource_by_id("R001")
            assert row is not None
            assert row["RESOURCENAME"] == "Machine1"

    def test_get_cache_status_includes_derived_index_freshness(self):
        import mes_dashboard.services.resource_cache as rc

        rc._resource_index = {
            **rc._new_empty_index(),
            "ready": True,
            "source": "redis",
            "version": "v1",
            "updated_at": "2026-02-07T10:00:00",
            "built_at": "2026-02-07T10:00:05",
            "count": 2,
        }

        mock_client = MagicMock()
        mock_client.exists.return_value = 1
        mock_client.get.side_effect = lambda key: {
            'mes_wip:resource:meta:count': '2',
            'mes_wip:resource:meta:version': 'v1',
            'mes_wip:resource:meta:updated': '2026-02-07T10:00:00',
        }.get(key)

        with patch.object(rc, "REDIS_ENABLED", True):
            with patch.object(rc, "RESOURCE_CACHE_ENABLED", True):
                with patch.object(rc, "get_redis_client", return_value=mock_client):
                    status = rc.get_cache_status()
                    assert status["derived_index"]["ready"] is True
                    assert status["derived_index"]["is_fresh"] is True

    def test_index_rebuilds_when_redis_version_changes(self):
        import mes_dashboard.services.resource_cache as rc

        rc._resource_index = {
            **rc._new_empty_index(),
            "ready": True,
            "source": "redis",
            "version": "v1",
            "updated_at": "2026-02-07T10:00:00",
            "built_at": "2026-02-07T10:00:05",
            "version_checked_at": 0.0,
            "count": 1,
            "all_positions": [0],
            "by_resource_id": {"OLD": 0},
        }

        rebuilt_df = pd.DataFrame([
            {"RESOURCEID": "R002", "RESOURCENAME": "Machine2"}
        ])

        with patch.object(rc, "RESOURCE_INDEX_VERSION_CHECK_INTERVAL", 0):
            with patch.object(rc, "_get_version_from_redis", return_value="v2"):
                with patch.object(rc, "_get_cached_data", return_value=rebuilt_df):
                    with patch.object(rc, "_get_cache_meta", return_value=("v2", "2026-02-07T10:10:00")):
                        snapshot = rc.get_resource_index_snapshot()
                        assert snapshot["version"] == "v2"
                        assert snapshot["count"] == 1
                        assert snapshot["by_resource_id"]["R002"] == 0
                        records = rc.get_all_resources()
                        assert records[0]["RESOURCENAME"] == "Machine2"

    def test_normalized_index_does_not_store_full_records_copy(self):
        import mes_dashboard.services.resource_cache as rc

        df = pd.DataFrame([
            {"RESOURCEID": "R001", "WORKCENTERNAME": "WC1", "PJ_ISPRODUCTION": 1},
            {"RESOURCEID": "R002", "WORKCENTERNAME": "WC2", "PJ_ISPRODUCTION": 0},
        ])

        index = rc._build_resource_index(df, source="redis", version="v1", updated_at="2026-02-08T00:00:00")
        assert "records" not in index
        assert index["by_resource_id"]["R001"] == 0
        assert index["by_resource_id"]["R002"] == 1
        assert index["memory"]["index_bytes"] > 0
        assert index["memory"]["records_json_bytes"] == 0


class TestResourceProcessLevelCache:
    """Test bounded process-level cache for resource data."""

    def test_lru_eviction_prefers_recent_keys(self):
        import mes_dashboard.services.resource_cache as rc

        cache = rc._ProcessLevelCache(ttl_seconds=60, max_size=2)
        df1 = pd.DataFrame([{"RESOURCEID": "R001"}])
        df2 = pd.DataFrame([{"RESOURCEID": "R002"}])
        df3 = pd.DataFrame([{"RESOURCEID": "R003"}])

        cache.set("a", df1)
        cache.set("b", df2)
        assert cache.get("a") is not None  # refresh recency for "a"
        cache.set("c", df3)  # should evict "b"

        assert cache.get("b") is None
        assert cache.get("a") is not None
        assert cache.get("c") is not None

    def test_resource_process_cache_uses_bounded_config(self):
        import mes_dashboard.services.resource_cache as rc

        assert rc.RESOURCE_PROCESS_CACHE_MAX_SIZE >= 1
        assert rc._resource_df_cache.max_size == rc.RESOURCE_PROCESS_CACHE_MAX_SIZE
