import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:test/test.dart';

import 'fixtures/sync_fixtures.dart';

void main() {
  late _FakeSyncedResourceRepository resources;
  late _FakeClock clock;
  late _FakeIds ids;

  setUp(() {
    clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
    ids = _FakeIds();
    resources = _FakeSyncedResourceRepository();
  });

  test(
    'push inserts, updates newer items and ignores older or equal items',
    () async {
      resources.put(
        _resource(
          serverId: 'existing-server-id',
          resourceType: SyncedResourceType.exercise,
          clientId: 'exercise-1',
          clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
          serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
          payloadJson: {'name': 'old'},
        ),
      );
      final useCase = PushSyncUseCase(
        resources: resources,
        clock: clock,
        ids: ids,
      );

      final result = await useCase.execute(
        ownerUserId: 'user-1',
        deviceId: 'device-1',
        items: [
          PushSyncItemInput(
            resourceType: 'program',
            clientId: 'program-1',
            schemaVersion: 1,
            clientUpdatedAt: DateTime.utc(2026, 7, 19, 9),
            deletedAt: null,
            payloadJson: {'name': 'program'},
          ),
          PushSyncItemInput(
            resourceType: 'exercise',
            clientId: 'exercise-1',
            schemaVersion: 1,
            clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
            deletedAt: null,
            payloadJson: {'name': 'same'},
          ),
          PushSyncItemInput(
            resourceType: 'exercise',
            clientId: 'exercise-1',
            schemaVersion: 2,
            clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, 1),
            deletedAt: null,
            payloadJson: {'name': 'new'},
          ),
          const PushSyncItemInput(
            resourceType: 'unknown',
            clientId: 'bad-1',
            schemaVersion: 1,
            clientUpdatedAt: null,
            deletedAt: null,
            payloadJson: {},
          ),
        ],
      );

      expect(result.results.map((item) => item.status), [
        'accepted',
        'ignoredOlder',
        'accepted',
        'error',
      ]);
      final updated = resources.get(
        'user-1',
        SyncedResourceType.exercise,
        'exercise-1',
      );
      expect(updated?.serverId, 'existing-server-id');
      expect(updated?.payloadJson, {'name': 'new'});
      expect(updated?.schemaVersion, 2);
      expect(updated?.originDeviceId, 'device-1');
    },
  );

  test('push and pull preserve versioned exercise fixtures strictly', () async {
    final push = PushSyncUseCase(resources: resources, clock: clock, ids: ids);
    final pull = PullSyncUseCase(resources: resources, clock: clock);
    final fixtures = loadExerciseFixtures();

    final pushResult = await push.execute(
      ownerUserId: 'user-1',
      deviceId: 'device-1',
      items: [
        for (var index = 0; index < fixtures.length; index += 1)
          PushSyncItemInput(
            resourceType: fixtures[index].resourceType,
            clientId: 'exercise-${fixtures[index].name}',
            schemaVersion: fixtures[index].schemaVersion,
            clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, index),
            deletedAt: null,
            payloadJson: fixtures[index].payload,
          ),
      ],
    );
    final pullResult = await pull.execute(ownerUserId: 'user-1');

    expect(
      pushResult.results.map((item) => item.status),
      everyElement('accepted'),
    );
    expect(pullResult.items, hasLength(fixtures.length));
    for (final fixture in fixtures) {
      final pulled = pullResult.items.singleWhere(
        (item) => item.clientId == 'exercise-${fixture.name}',
      );
      expect(pulled.resourceType, SyncedResourceType.exercise);
      expect(pulled.schemaVersion, fixture.schemaVersion);
      expect(pulled.payloadJson, fixture.payload);
    }
  });

  test(
    'push and pull preserve exercise fixture batches across versions',
    () async {
      final push = PushSyncUseCase(
        resources: resources,
        clock: clock,
        ids: ids,
      );
      final pull = PullSyncUseCase(resources: resources, clock: clock);
      final fixtures = [
        loadExerciseFixture('minimal'),
        loadExerciseFixture('minimal', version: 'v2'),
      ];

      final pushResult = await push.execute(
        ownerUserId: 'user-1',
        deviceId: 'device-1',
        items: [
          for (var index = 0; index < fixtures.length; index += 1)
            PushSyncItemInput(
              resourceType: fixtures[index].resourceType,
              clientId: 'exercise-versioned-$index',
              schemaVersion: fixtures[index].schemaVersion,
              clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, index),
              deletedAt: null,
              payloadJson: fixtures[index].payload,
            ),
        ],
      );
      final pullResult = await pull.execute(ownerUserId: 'user-1');

      expect(pushResult.results.map((item) => item.status), [
        'accepted',
        'accepted',
      ]);
      for (var index = 0; index < fixtures.length; index += 1) {
        final pulled = pullResult.items.singleWhere(
          (item) => item.clientId == 'exercise-versioned-$index',
        );
        expect(pulled.schemaVersion, fixtures[index].schemaVersion);
        expect(pulled.payloadJson, fixtures[index].payload);
      }
    },
  );

  test(
    'push and pull preserve reusable non-exercise fixtures strictly',
    () async {
      final push = PushSyncUseCase(
        resources: resources,
        clock: clock,
        ids: ids,
      );
      final pull = PullSyncUseCase(resources: resources, clock: clock);
      final fixtures = loadMinimalResourceFixtures();

      final pushResult = await push.execute(
        ownerUserId: 'user-1',
        deviceId: 'device-1',
        items: [
          for (var index = 0; index < fixtures.length; index += 1)
            PushSyncItemInput(
              resourceType: fixtures[index].resourceType,
              clientId: '${fixtures[index].resourceType}-minimal',
              schemaVersion: fixtures[index].schemaVersion,
              clientUpdatedAt: DateTime.utc(2026, 7, 19, 11, index),
              deletedAt: null,
              payloadJson: fixtures[index].payload,
            ),
        ],
      );
      final pullResult = await pull.execute(ownerUserId: 'user-1');

      expect(
        pushResult.results.map((item) => item.status),
        everyElement('accepted'),
      );
      for (final fixture in fixtures) {
        final pulled = pullResult.items.singleWhere(
          (item) => item.clientId == '${fixture.resourceType}-minimal',
        );
        expect(pulled.resourceType.wireName, fixture.resourceType);
        expect(pulled.schemaVersion, fixture.schemaVersion);
        expect(pulled.payloadJson, fixture.payload);
      }
    },
  );

  test(
    'LWW ignores older and equal exercise fixture payloads strictly',
    () async {
      final original = loadExerciseFixture('full_combo');
      final newer = loadExerciseFixture('with_timers');
      final push = PushSyncUseCase(
        resources: resources,
        clock: clock,
        ids: ids,
      );

      final result = await push.execute(
        ownerUserId: 'user-1',
        deviceId: 'device-1',
        items: [
          PushSyncItemInput(
            resourceType: original.resourceType,
            clientId: 'exercise-lww',
            schemaVersion: original.schemaVersion,
            clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
            deletedAt: null,
            payloadJson: original.payload,
          ),
          PushSyncItemInput(
            resourceType: newer.resourceType,
            clientId: 'exercise-lww',
            schemaVersion: newer.schemaVersion,
            clientUpdatedAt: DateTime.utc(2026, 7, 19, 9, 59),
            deletedAt: null,
            payloadJson: newer.payload,
          ),
          PushSyncItemInput(
            resourceType: newer.resourceType,
            clientId: 'exercise-lww',
            schemaVersion: newer.schemaVersion,
            clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
            deletedAt: null,
            payloadJson: newer.payload,
          ),
        ],
      );

      expect(result.results.map((item) => item.status), [
        'accepted',
        'ignoredOlder',
        'ignoredOlder',
      ]);
      final stored = resources.get(
        'user-1',
        SyncedResourceType.exercise,
        'exercise-lww',
      );
      expect(stored?.schemaVersion, original.schemaVersion);
      expect(stored?.payloadJson, original.payload);
    },
  );

  test('push reports per-item malformations without storing them', () async {
    final useCase = PushSyncUseCase(
      resources: resources,
      clock: clock,
      ids: ids,
    );

    final result = await useCase.execute(
      ownerUserId: 'user-1',
      deviceId: 'device-1',
      items: [
        PushSyncItemInput(
          resourceType: 'exercise',
          clientId: 'bad-schema',
          schemaVersion: 0,
          clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
          deletedAt: null,
          payloadJson: {},
        ),
        const PushSyncItemInput(
          resourceType: 'exercise',
          clientId: 'missing-updated-at',
          schemaVersion: 1,
          clientUpdatedAt: null,
          deletedAt: null,
          payloadJson: {},
        ),
        const PushSyncItemInput.invalid(
          resourceType: 'exercise',
          clientId: 'bad-payload',
          message: 'payload must be a JSON object.',
        ),
      ],
    );

    expect(result.results.map((item) => item.status), [
      'error',
      'error',
      'error',
    ]);
    expect(resources._items, isEmpty);
  });

  test(
    'pull returns resources newer than cursor including soft deletes',
    () async {
      resources
        ..put(
          _resource(
            serverId: 'resource-1',
            resourceType: SyncedResourceType.exercise,
            clientId: 'exercise-1',
            serverUpdatedAt: DateTime.utc(2026, 7, 19, 10),
          ),
        )
        ..put(
          _resource(
            serverId: 'resource-2',
            resourceType: SyncedResourceType.program,
            clientId: 'program-1',
            serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
            deletedAt: DateTime.utc(2026, 7, 19, 10, 30),
          ),
        );
      final useCase = PullSyncUseCase(resources: resources, clock: clock);

      final result = await useCase.execute(
        ownerUserId: 'user-1',
        since: DateTime.utc(2026, 7, 19, 10, 30),
      );

      expect(result.items.map((item) => item.clientId), ['program-1']);
      expect(result.items.single.deletedAt, DateTime.utc(2026, 7, 19, 10, 30));
      expect(result.serverCursor, DateTime.utc(2026, 7, 19, 11));
    },
  );

  test('exchange pushes first then pulls the updated state', () async {
    final push = PushSyncUseCase(resources: resources, clock: clock, ids: ids);
    final pull = PullSyncUseCase(resources: resources, clock: clock);
    final useCase = ExchangeSyncUseCase(push: push, pull: pull);

    final result = await useCase.execute(
      ownerUserId: 'user-1',
      deviceId: 'device-1',
      items: [
        PushSyncItemInput(
          resourceType: 'mediaAsset',
          clientId: 'media-1',
          schemaVersion: 1,
          clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
          deletedAt: null,
          payloadJson: {'localUri': 'file://image.jpg'},
        ),
      ],
    );

    expect(result.push.results.single.status, 'accepted');
    expect(result.pull.items.single.clientId, 'media-1');
  });
}

SyncedResource _resource({
  required String serverId,
  required SyncedResourceType resourceType,
  required String clientId,
  DateTime? clientUpdatedAt,
  DateTime? serverUpdatedAt,
  DateTime? deletedAt,
  Map<String, Object?> payloadJson = const {},
}) {
  return SyncedResource(
    serverId: serverId,
    ownerUserId: 'user-1',
    resourceType: resourceType,
    clientId: clientId,
    payloadJson: payloadJson,
    schemaVersion: 1,
    clientUpdatedAt: clientUpdatedAt ?? DateTime.utc(2026, 7, 19, 9),
    serverUpdatedAt: serverUpdatedAt ?? DateTime.utc(2026, 7, 19, 10),
    deletedAt: deletedAt,
    originDeviceId: 'device-1',
  );
}

final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
  final _items = <String, SyncedResource>{};

  void put(SyncedResource resource) {
    _items[_key(
          resource.ownerUserId,
          resource.resourceType,
          resource.clientId,
        )] =
        resource;
  }

  SyncedResource? get(
    String ownerUserId,
    SyncedResourceType resourceType,
    String clientId,
  ) {
    return _items[_key(ownerUserId, resourceType, clientId)];
  }

  @override
  Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
    final key = _key(
      resource.ownerUserId,
      resource.resourceType,
      resource.clientId,
    );
    final existing = _items[key];
    if (existing != null &&
        !resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) {
      return SyncWriteResult(
        status: SyncWriteStatus.ignoredOlder,
        resource: existing,
      );
    }
    final written = existing == null
        ? resource
        : SyncedResource(
            serverId: existing.serverId,
            ownerUserId: existing.ownerUserId,
            resourceType: existing.resourceType,
            clientId: existing.clientId,
            payloadJson: resource.payloadJson,
            schemaVersion: resource.schemaVersion,
            clientUpdatedAt: resource.clientUpdatedAt,
            serverUpdatedAt: resource.serverUpdatedAt,
            deletedAt: resource.deletedAt,
            originDeviceId: resource.originDeviceId,
          );
    _items[key] = written;
    return SyncWriteResult(status: SyncWriteStatus.accepted, resource: written);
  }

  @override
  Future<List<SyncedResource>> findAllForUserSince({
    required String ownerUserId,
    DateTime? since,
  }) async {
    final result =
        _items.values
            .where((item) => item.ownerUserId == ownerUserId)
            .where(
              (item) =>
                  since == null || item.serverUpdatedAt.isAfter(since.toUtc()),
            )
            .toList()
          ..sort(
            (left, right) =>
                left.serverUpdatedAt.compareTo(right.serverUpdatedAt),
          );
    return result;
  }
}

String _key(
  String ownerUserId,
  SyncedResourceType resourceType,
  String clientId,
) {
  return '$ownerUserId:${resourceType.wireName}:$clientId';
}

final class _FakeClock implements Clock {
  _FakeClock(this.value);

  DateTime value;

  @override
  DateTime now() => value;
}

final class _FakeIds implements IdGenerator {
  var _next = 0;

  @override
  String newId() {
    _next += 1;
    return 'id-$_next';
  }
}
