"""Record differ for CloudKit uploads. This module compares local records with CloudKit records to determine what needs to be created, updated, or deleted. """ from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import Any, Optional from ..models.game import Game from ..models.team import Team from ..models.stadium import Stadium from .cloudkit import CloudKitRecord, RecordType class DiffAction(str, Enum): """Action to take for a record.""" CREATE = "create" UPDATE = "update" DELETE = "delete" UNCHANGED = "unchanged" @dataclass class RecordDiff: """Represents the difference between local and remote records. Attributes: record_name: Canonical record ID record_type: CloudKit record type action: Action to take (create, update, delete, unchanged) local_record: Local CloudKitRecord (None if delete) remote_record: Remote record dict (None if create) changed_fields: List of field names that changed (for update) record_change_tag: Remote record's change tag (for update) """ record_name: str record_type: RecordType action: DiffAction local_record: Optional[CloudKitRecord] = None remote_record: Optional[dict] = None changed_fields: list[str] = field(default_factory=list) record_change_tag: Optional[str] = None @dataclass class DiffResult: """Result of diffing local and remote records. Attributes: creates: Records to create updates: Records to update deletes: Records to delete (record names) unchanged: Records with no changes """ creates: list[RecordDiff] = field(default_factory=list) updates: list[RecordDiff] = field(default_factory=list) deletes: list[RecordDiff] = field(default_factory=list) unchanged: list[RecordDiff] = field(default_factory=list) @property def create_count(self) -> int: return len(self.creates) @property def update_count(self) -> int: return len(self.updates) @property def delete_count(self) -> int: return len(self.deletes) @property def unchanged_count(self) -> int: return len(self.unchanged) @property def total_changes(self) -> int: return self.create_count + self.update_count + self.delete_count def get_records_to_upload(self) -> list[CloudKitRecord]: """Get all records that need to be uploaded (creates + updates).""" records = [] for diff in self.creates: if diff.local_record: records.append(diff.local_record) for diff in self.updates: if diff.local_record: # Add change tag for update diff.local_record.record_change_tag = diff.record_change_tag records.append(diff.local_record) return records class RecordDiffer: """Compares local records with CloudKit records.""" # Fields to compare for each record type GAME_FIELDS = [ "sport", "season", "home_team_id", "away_team_id", "stadium_id", "game_date", "game_number", "home_score", "away_score", "status", ] TEAM_FIELDS = [ "sport", "city", "name", "full_name", "abbreviation", "conference", "division", "primary_color", "secondary_color", "logo_url", "stadium_id", ] STADIUM_FIELDS = [ "sport", "name", "city", "state", "country", "latitude", "longitude", "capacity", "surface", "roof_type", "opened_year", "image_url", "timezone", ] def diff_games( self, local_games: list[Game], remote_records: list[dict], ) -> DiffResult: """Diff local games against remote CloudKit records. Args: local_games: List of local Game objects remote_records: List of remote record dictionaries Returns: DiffResult with creates, updates, deletes """ local_records = [self._game_to_record(g) for g in local_games] return self._diff_records( local_records, remote_records, RecordType.GAME, self.GAME_FIELDS, ) def diff_teams( self, local_teams: list[Team], remote_records: list[dict], ) -> DiffResult: """Diff local teams against remote CloudKit records. Args: local_teams: List of local Team objects remote_records: List of remote record dictionaries Returns: DiffResult with creates, updates, deletes """ local_records = [self._team_to_record(t) for t in local_teams] return self._diff_records( local_records, remote_records, RecordType.TEAM, self.TEAM_FIELDS, ) def diff_stadiums( self, local_stadiums: list[Stadium], remote_records: list[dict], ) -> DiffResult: """Diff local stadiums against remote CloudKit records. Args: local_stadiums: List of local Stadium objects remote_records: List of remote record dictionaries Returns: DiffResult with creates, updates, deletes """ local_records = [self._stadium_to_record(s) for s in local_stadiums] return self._diff_records( local_records, remote_records, RecordType.STADIUM, self.STADIUM_FIELDS, ) def _diff_records( self, local_records: list[CloudKitRecord], remote_records: list[dict], record_type: RecordType, compare_fields: list[str], ) -> DiffResult: """Compare local and remote records. Args: local_records: List of local CloudKitRecord objects remote_records: List of remote record dictionaries record_type: Type of records being compared compare_fields: List of field names to compare Returns: DiffResult with categorized differences """ result = DiffResult() # Index remote records by name remote_by_name: dict[str, dict] = {} for record in remote_records: name = record.get("recordName") if name: remote_by_name[name] = record # Index local records by name local_by_name: dict[str, CloudKitRecord] = {} for record in local_records: local_by_name[record.record_name] = record # Find creates and updates for local_record in local_records: remote = remote_by_name.get(local_record.record_name) if remote is None: # New record result.creates.append(RecordDiff( record_name=local_record.record_name, record_type=record_type, action=DiffAction.CREATE, local_record=local_record, )) else: # Check for changes changed_fields = self._compare_fields( local_record.fields, remote.get("fields", {}), compare_fields, ) if changed_fields: result.updates.append(RecordDiff( record_name=local_record.record_name, record_type=record_type, action=DiffAction.UPDATE, local_record=local_record, remote_record=remote, changed_fields=changed_fields, record_change_tag=remote.get("recordChangeTag"), )) else: result.unchanged.append(RecordDiff( record_name=local_record.record_name, record_type=record_type, action=DiffAction.UNCHANGED, local_record=local_record, remote_record=remote, record_change_tag=remote.get("recordChangeTag"), )) # Find deletes (remote records not in local) local_names = set(local_by_name.keys()) for remote_name, remote in remote_by_name.items(): if remote_name not in local_names: result.deletes.append(RecordDiff( record_name=remote_name, record_type=record_type, action=DiffAction.DELETE, remote_record=remote, record_change_tag=remote.get("recordChangeTag"), )) return result def _compare_fields( self, local_fields: dict[str, Any], remote_fields: dict[str, dict], compare_fields: list[str], ) -> list[str]: """Compare field values between local and remote. Args: local_fields: Local field values remote_fields: Remote field values (CloudKit format) compare_fields: Fields to compare Returns: List of field names that differ """ changed = [] for field_name in compare_fields: local_value = local_fields.get(field_name) remote_field = remote_fields.get(field_name, {}) remote_value = remote_field.get("value") if remote_field else None # Normalize values for comparison local_normalized = self._normalize_value(local_value) remote_normalized = self._normalize_remote_value(remote_value, remote_field) if local_normalized != remote_normalized: changed.append(field_name) return changed def _normalize_value(self, value: Any) -> Any: """Normalize a local value for comparison.""" if value is None: return None if isinstance(value, datetime): # Convert to milliseconds since epoch return int(value.timestamp() * 1000) if isinstance(value, float): # Round to 6 decimal places for coordinate comparison return round(value, 6) return value def _normalize_remote_value(self, value: Any, field_data: dict) -> Any: """Normalize a remote CloudKit value for comparison.""" if value is None: return None field_type = field_data.get("type", "") if field_type == "TIMESTAMP": # Already in milliseconds return value if field_type == "DOUBLE": return round(value, 6) if field_type == "LOCATION": # Return as tuple for comparison if isinstance(value, dict): return ( round(value.get("latitude", 0), 6), round(value.get("longitude", 0), 6), ) return value def _game_to_record(self, game: Game) -> CloudKitRecord: """Convert a Game to a CloudKitRecord.""" return CloudKitRecord( record_name=game.id, record_type=RecordType.GAME, fields={ "sport": game.sport, "season": game.season, "home_team_id": game.home_team_id, "away_team_id": game.away_team_id, "stadium_id": game.stadium_id, "game_date": game.game_date, "game_number": game.game_number, "home_score": game.home_score, "away_score": game.away_score, "status": game.status, }, ) def _team_to_record(self, team: Team) -> CloudKitRecord: """Convert a Team to a CloudKitRecord.""" return CloudKitRecord( record_name=team.id, record_type=RecordType.TEAM, fields={ "sport": team.sport, "city": team.city, "name": team.name, "full_name": team.full_name, "abbreviation": team.abbreviation, "conference": team.conference, "division": team.division, "primary_color": team.primary_color, "secondary_color": team.secondary_color, "logo_url": team.logo_url, "stadium_id": team.stadium_id, }, ) def _stadium_to_record(self, stadium: Stadium) -> CloudKitRecord: """Convert a Stadium to a CloudKitRecord.""" return CloudKitRecord( record_name=stadium.id, record_type=RecordType.STADIUM, fields={ "sport": stadium.sport, "name": stadium.name, "city": stadium.city, "state": stadium.state, "country": stadium.country, "latitude": stadium.latitude, "longitude": stadium.longitude, "capacity": stadium.capacity, "surface": stadium.surface, "roof_type": stadium.roof_type, "opened_year": stadium.opened_year, "image_url": stadium.image_url, "timezone": stadium.timezone, }, ) def game_to_cloudkit_record(game: Game) -> CloudKitRecord: """Convert a Game to a CloudKitRecord. Convenience function for external use. """ differ = RecordDiffer() return differ._game_to_record(game) def team_to_cloudkit_record(team: Team) -> CloudKitRecord: """Convert a Team to a CloudKitRecord. Convenience function for external use. """ differ = RecordDiffer() return differ._team_to_record(team) def stadium_to_cloudkit_record(stadium: Stadium) -> CloudKitRecord: """Convert a Stadium to a CloudKitRecord. Convenience function for external use. """ differ = RecordDiffer() return differ._stadium_to_record(stadium)