# Task ID: 5

**Title:** Implement CacheManager

**Status:** pending

**Dependencies:** 2, 3

**Priority:** high

**Description:** Create the CacheManager for token caching, CSV file reading, and response caching with TTL support.

**Details:**

Create `src/teams_phone/infrastructure/cache_manager.py`:
```python
class CacheManager:
    def __init__(self, cache_dir: Path | None = None, token_dir: Path | None = None):
        self._cache_dir = cache_dir or Path(DEFAULT_CACHE_PATH).expanduser()
        self._token_dir = token_dir or Path(DEFAULT_TOKEN_PATH).expanduser()
    
    # Token management
    def get_token(self, tenant_name: str) -> CachedToken | None: ...
    def save_token(self, tenant_name: str, token: CachedToken) -> None: ...
    def clear_tokens(self, tenant_name: str | None = None) -> None: ...
    
    # CSV cache
    def read_locations_csv(self) -> list[dict]: ...
    def read_policies_csv(self) -> list[dict]: ...
    def get_csv_metadata(self) -> CacheMetadata | None: ...
    def get_cache_age_days(self) -> int | None: ...
    def is_cache_stale(self) -> bool: ...
```

Token files stored as JSON. CSV reading uses stdlib csv module.
Return empty list if CSV missing (graceful degradation).

**Test Strategy:**

Unit tests: token save/load/clear, CSV reading with sample fixtures, staleness detection, missing file handling, metadata parsing.

---

## Subtask 5.1: Token Storage and Retrieval

### Implementation Notes

#### Session: 2026-01-28 18:55 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Created `src/teams_phone/infrastructure/cache_manager.py` with CacheManager class
- Implemented `__init__` with `cache_dir` and `token_dir` parameters (defaulting to constants)
- Implemented `ensure_token_dir()` helper that creates directory with parents, raises ConfigurationError on PermissionError
- Implemented `_get_token_path(tenant_name)` private helper for file path construction
- Implemented `get_token(tenant_name)` that reads JSON and deserializes to CachedToken, returns None if missing/invalid
- Implemented `save_token(tenant_name, token)` that writes token to JSON using Pydantic's `model_dump_json()`
- Implemented `clear_tokens(tenant_name)` that deletes specific or all `.json` files (preserves directory)
- Updated `src/teams_phone/infrastructure/__init__.py` to export CacheManager
- Created comprehensive unit tests: `tests/unit/test_cache_manager.py` (23 tests)

#### Key Decisions Made
- **Properties for directories**: Used `@property` decorators for `cache_dir` and `token_dir` to allow external read access while keeping `_cache_dir` and `_token_dir` as private Path instances
- **Silent failure for invalid tokens**: `get_token()` returns None for missing files, invalid JSON, or Pydantic validation errors (consistent with recon guidance for graceful degradation)
- **PydanticValidationError aliased import**: Used `from pydantic import ValidationError as PydanticValidationError` to avoid confusion with our `teams_phone.exceptions.ValidationError`
- **clear_tokens(None) clears files only**: Keeps the token directory intact, only removes `.json` files as specified in acceptance criteria

#### Patterns Discovered
- **Path expansion pattern**: `Path(str_or_none or DEFAULT).expanduser()` - see `config_manager.py:37-38`
- **Directory creation with PermissionError**: `mkdir(parents=True, exist_ok=True)` wrapped in try/except - see `config_manager.py:48-57`
- **Pydantic JSON serialization**: `model.model_dump_json()` for saving, `Model.model_validate_json(content)` for loading

#### Open Items for Next Agent
- [ ] Subtask 5.2: Implement CSV file reading methods (`read_locations_csv`, `read_policies_csv`)
- [ ] Subtask 5.3: Implement staleness detection (`get_csv_metadata`, `get_cache_age_days`, `is_cache_stale`)

#### Gotchas and Warnings
- **extra="forbid" on CachedToken**: Any extra fields in the JSON will cause validation to fail - `get_token()` returns None in this case
- **datetime serialization**: Pydantic handles ISO format automatically, but be aware of timezone handling - `CachedToken.is_expired()` normalizes naive datetimes to UTC

---

## Subtask 5.2: CSV Reading with Graceful Degradation

### Implementation Notes

#### Session: 2026-01-28 21:20 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `import csv` to `src/teams_phone/infrastructure/cache_manager.py`
- Implemented `_read_csv(filename: str) -> list[dict[str, str]]` private helper method
- Implemented `read_locations_csv() -> list[dict[str, str]]` calling `_read_csv("locations.csv")`
- Implemented `read_policies_csv() -> list[dict[str, str]]` calling `_read_csv("policies.csv")`
- Added 11 unit tests for CSV reading functionality in `tests/unit/test_cache_manager.py`

#### Key Decisions Made
- **UTF-8-sig encoding**: Used `encoding="utf-8-sig"` instead of `utf-8` to handle PowerShell exports that include a BOM (Byte Order Mark). This transparently handles both BOM and non-BOM files.
- **Return empty list on all errors**: Following the graceful degradation pattern from 5.1, returns `[]` for missing files, empty files, and malformed CSV. No exceptions raised for CSV reading failures.
- **csv.Error catch only**: Only catching `csv.Error` explicitly, not broader exceptions. Other I/O errors (permissions, etc.) will propagate up as they indicate system issues rather than expected graceful degradation scenarios.
- **Return type `list[dict[str, str]]`**: Using strict type hint since `csv.DictReader` yields `dict[str, str]` (all values are strings).

#### Patterns Discovered
- **csv.DictReader handles multiline values**: Quoted values with embedded newlines are correctly parsed
- **csv.DictReader handles special characters**: Commas and quotes within quoted values handled automatically
- **Path.open() vs open()**: Using `csv_path.open(encoding="utf-8-sig")` is cleaner and more consistent with pathlib patterns

#### Open Items for Next Agent
- [x] Subtask 5.2: Implement CSV file reading methods - DONE
- [ ] Subtask 5.3: Implement staleness detection (`get_csv_metadata`, `get_cache_age_days`, `is_cache_stale`)

#### Gotchas and Warnings
- **BOM in first column name**: Without `utf-8-sig` encoding, a BOM character would be prepended to the first column name (e.g., `\ufefflocationId` instead of `locationId`). Tests verify this is handled correctly.
- **Empty file vs header-only**: Both return `[]` - an empty file has no header so DictReader has no field names, header-only file has fields but no data rows.
- **All CSV values are strings**: Even boolean-looking values like `True`/`False` and numeric values are strings. Service layer should convert as needed.

---

## Subtask 5.3: Cache Metadata and Staleness Detection

### Implementation Notes

#### Session: 2026-01-28 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Created `src/teams_phone/models/cache.py` with `CacheMetadata` Pydantic model
- Exported `CacheMetadata` from `src/teams_phone/models/__init__.py`
- Implemented `get_csv_metadata() -> CacheMetadata | None` in `cache_manager.py`
- Implemented `get_cache_age_days() -> int | None` in `cache_manager.py`
- Implemented `is_cache_stale() -> bool` in `cache_manager.py`
- Added 12 unit tests covering all new methods in `tests/unit/test_cache_manager.py`

#### Key Decisions Made
- **CacheMetadata in separate file**: Created `models/cache.py` rather than inline in `cache_manager.py` for consistency with other domain models (`auth.py`, `config.py`, `tenant.py`)
- **Oldest mtime for last_updated**: When both `locations.csv` and `policies.csv` exist, uses the OLDER modification time. This indicates when the cache was last "completely" updated (both files refreshed together).
- **Race condition handling**: Wrapped `stat()` calls in try/except for `FileNotFoundError` in case file is deleted between existence check and stat.
- **Reuse existing methods for row counts**: Called `read_locations_csv()` and `read_policies_csv()` to get row counts rather than re-reading files. This leverages existing UTF-8-sig handling and error handling.
- **Stale threshold uses `>` not `>=`**: `is_cache_stale()` returns `True` only when `age_days > CSV_STALE_DAYS`, so exactly 30 days old is NOT stale.

#### Patterns Discovered
- **Datetime from file mtime**: `datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)` for cross-platform UTC conversion
- **Days between datetimes**: `(now - last_updated).days` gives integer day count
- **os.utime for tests**: Used `os.utime(path, (timestamp, timestamp))` to set file mtimes in tests for deterministic age testing

#### Open Items for Next Agent
- [x] Subtask 5.3: Implement staleness detection - DONE
- Task 5 (CacheManager) is now fully complete

#### Gotchas and Warnings
- **File may disappear between checks**: Even though we check `path.exists()` first, a try/except around `stat()` is needed for race conditions
- **last_updated can be None in CacheMetadata**: When `get_csv_metadata()` returns metadata but both files were deleted during stat(), `last_updated` could theoretically be None even with a non-None CacheMetadata
- **CSV_STALE_DAYS = 30**: Defined in `constants.py:44`. If this changes, the staleness logic automatically uses the new value.

---
