import 'dart:convert';

import 'package:gametime_server/api/router.dart';
import 'package:gametime_server/api/sync_api.dart';
import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:shelf/shelf.dart';
import 'package:test/test.dart';

import 'fixtures/sync_fixtures.dart';

void main() {
  test('sync routes require bearer authentication', () async {
    final handler = buildApiHandler(syncApi: _syncApi());

    final response = await handler(
      Request('GET', Uri.parse('http://localhost/sync/pull')),
    );

    expect(response.statusCode, 401);
  });

  test('push parses item errors without blocking valid items', () async {
    final repository = _FakeSyncedResourceRepository();
    final handler = buildApiHandler(syncApi: _syncApi(resources: repository));

    final response = await handler(
      Request(
        'POST',
        Uri.parse('http://localhost/sync/push'),
        headers: {'authorization': 'Bearer valid-token'},
        body: jsonEncode({
          'deviceId': 'device-1',
          'items': [
            {
              'resourceType': 'exercise',
              'clientId': 'exercise-1',
              'schemaVersion': 1,
              'clientUpdatedAt': '2026-07-19T10:00:00Z',
              'deletedAt': null,
              'payload': {'name': 'Squat'},
            },
            {
              'resourceType': 'bad',
              'clientId': 'bad-1',
              'schemaVersion': 1,
              'clientUpdatedAt': '2026-07-19T10:00:00Z',
              'payload': {},
            },
            'malformed',
          ],
        }),
      ),
    );

    final body = jsonDecode(await response.readAsString()) as Map;
    final results = body['results'] as List;

    expect(response.statusCode, 200);
    expect(results.map((item) => (item as Map)['status']), [
      'accepted',
      'error',
      'error',
    ]);
    expect(repository.items.single.clientId, 'exercise-1');
  });

  test(
    'push and pull round-trip versioned exercise fixtures strictly',
    () async {
      final repository = _FakeSyncedResourceRepository();
      final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
      final fixtures = loadExerciseFixtures();

      final pushResponse = await handler(
        Request(
          'POST',
          Uri.parse('http://localhost/sync/push'),
          headers: {'authorization': 'Bearer valid-token'},
          body: jsonEncode({
            'deviceId': 'device-1',
            'items': [
              for (var index = 0; index < fixtures.length; index += 1)
                {
                  'resourceType': fixtures[index].resourceType,
                  'clientId': 'exercise-${fixtures[index].name}',
                  'schemaVersion': fixtures[index].schemaVersion,
                  'clientUpdatedAt':
                      '2026-07-19T10:${index.toString().padLeft(2, '0')}:00Z',
                  'deletedAt': null,
                  'payload': fixtures[index].payload,
                },
            ],
          }),
        ),
      );
      final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;

      final pullResponse = await handler(
        Request(
          'GET',
          Uri.parse('http://localhost/sync/pull'),
          headers: {'authorization': 'Bearer valid-token'},
        ),
      );
      final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
      final pulledItems = pullBody['items'] as List;

      expect(pushResponse.statusCode, 200);
      expect(
        (pushBody['results'] as List).map((item) => (item as Map)['status']),
        everyElement('accepted'),
      );
      expect(pullResponse.statusCode, 200);
      expect(pulledItems, hasLength(fixtures.length));
      for (final fixture in fixtures) {
        final pulled = pulledItems.cast<Map>().singleWhere(
          (item) => item['clientId'] == 'exercise-${fixture.name}',
        );
        expect(pulled['resourceType'], 'exercise');
        expect(pulled['schemaVersion'], fixture.schemaVersion);
        expect(pulled['payload'], fixture.payload);
      }
    },
  );

  test('push and pull round-trip workoutTemplate fixture strictly', () async {
    final repository = _FakeSyncedResourceRepository();
    final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
    final fixture = loadWorkoutTemplateFixture('minimal');

    final pushResponse = await handler(
      Request(
        'POST',
        Uri.parse('http://localhost/sync/push'),
        headers: {'authorization': 'Bearer valid-token'},
        body: jsonEncode({
          'deviceId': 'device-1',
          'items': [
            {
              'resourceType': fixture.resourceType,
              'clientId': 'template-minimal',
              'schemaVersion': fixture.schemaVersion,
              'clientUpdatedAt': '2026-07-19T10:30:00Z',
              'payload': fixture.payload,
            },
          ],
        }),
      ),
    );
    final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;

    final pullResponse = await handler(
      Request(
        'GET',
        Uri.parse('http://localhost/sync/pull'),
        headers: {'authorization': 'Bearer valid-token'},
      ),
    );
    final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
    final pulled = (pullBody['items'] as List).cast<Map>().single;

    expect(pushResponse.statusCode, 200);
    expect(((pushBody['results'] as List).single as Map)['status'], 'accepted');
    expect(pullResponse.statusCode, 200);
    expect(pulled['resourceType'], 'workoutTemplate');
    expect(pulled['clientId'], 'template-minimal');
    expect(pulled['schemaVersion'], fixture.schemaVersion);
    expect(pulled['payload'], fixture.payload);
  });

  test(
    'push and pull round-trip all reusable minimal resource fixtures',
    () async {
      final repository = _FakeSyncedResourceRepository();
      final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
      final fixtures = loadMinimalResourceFixtures();

      final pushResponse = await handler(
        Request(
          'POST',
          Uri.parse('http://localhost/sync/push'),
          headers: {'authorization': 'Bearer valid-token'},
          body: jsonEncode({
            'deviceId': 'device-1',
            'items': [
              for (var index = 0; index < fixtures.length; index += 1)
                {
                  'resourceType': fixtures[index].resourceType,
                  'clientId': '${fixtures[index].resourceType}-minimal',
                  'schemaVersion': fixtures[index].schemaVersion,
                  'clientUpdatedAt':
                      '2026-07-19T11:${index.toString().padLeft(2, '0')}:00Z',
                  'payload': fixtures[index].payload,
                },
            ],
          }),
        ),
      );
      final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;

      final pullResponse = await handler(
        Request(
          'GET',
          Uri.parse('http://localhost/sync/pull'),
          headers: {'authorization': 'Bearer valid-token'},
        ),
      );
      final pulledItems =
          (jsonDecode(await pullResponse.readAsString()) as Map)['items']
              as List;

      expect(pushResponse.statusCode, 200);
      expect(
        (pushBody['results'] as List).map((item) => (item as Map)['status']),
        everyElement('accepted'),
      );
      expect(pullResponse.statusCode, 200);
      for (final fixture in fixtures) {
        final pulled = pulledItems.cast<Map>().singleWhere(
          (item) => item['clientId'] == '${fixture.resourceType}-minimal',
        );
        expect(pulled['resourceType'], fixture.resourceType);
        expect(pulled['schemaVersion'], fixture.schemaVersion);
        expect(pulled['payload'], fixture.payload);
      }
    },
  );

  test(
    'push and pull preserve exercise fixture batches across versions',
    () async {
      final repository = _FakeSyncedResourceRepository();
      final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
      final fixtures = [
        loadExerciseFixture('minimal'),
        loadExerciseFixture('minimal', version: 'v2'),
      ];

      final pushResponse = await handler(
        Request(
          'POST',
          Uri.parse('http://localhost/sync/push'),
          headers: {'authorization': 'Bearer valid-token'},
          body: jsonEncode({
            'deviceId': 'device-1',
            'items': [
              for (var index = 0; index < fixtures.length; index += 1)
                {
                  'resourceType': 'exercise',
                  'clientId': 'exercise-versioned-$index',
                  'schemaVersion': fixtures[index].schemaVersion,
                  'clientUpdatedAt':
                      '2026-07-19T12:${index.toString().padLeft(2, '0')}:00Z',
                  'payload': fixtures[index].payload,
                },
            ],
          }),
        ),
      );
      final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;

      final pullResponse = await handler(
        Request(
          'GET',
          Uri.parse('http://localhost/sync/pull'),
          headers: {'authorization': 'Bearer valid-token'},
        ),
      );
      final pulledItems =
          (jsonDecode(await pullResponse.readAsString()) as Map)['items']
              as List;

      expect(pushResponse.statusCode, 200);
      expect(
        (pushBody['results'] as List).map((item) => (item as Map)['status']),
        everyElement('accepted'),
      );
      expect(pullResponse.statusCode, 200);
      for (var index = 0; index < fixtures.length; index += 1) {
        final pulled = pulledItems.cast<Map>().singleWhere(
          (item) => item['clientId'] == 'exercise-versioned-$index',
        );
        expect(pulled['schemaVersion'], fixtures[index].schemaVersion);
        expect(pulled['payload'], fixtures[index].payload);
      }
    },
  );

  test('push delete stores deletedAt and pull returns the tombstone', () async {
    final repository = _FakeSyncedResourceRepository();
    final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
    final fixture = loadWorkoutTemplateFixture('minimal');

    final response = await handler(
      Request(
        'POST',
        Uri.parse('http://localhost/sync/push'),
        headers: {'authorization': 'Bearer valid-token'},
        body: jsonEncode({
          'deviceId': 'device-1',
          'items': [
            {
              'resourceType': 'workoutTemplate',
              'clientId': 'template-delete',
              'schemaVersion': fixture.schemaVersion,
              'clientUpdatedAt': '2026-07-19T10:30:00Z',
              'deletedAt': null,
              'payload': fixture.payload,
            },
            {
              'resourceType': 'workoutTemplate',
              'clientId': 'template-delete',
              'schemaVersion': fixture.schemaVersion,
              'clientUpdatedAt': '2026-07-19T10:31:00Z',
              'deletedAt': '2026-07-19T10:31:00Z',
              'payload': fixture.payload,
            },
          ],
        }),
      ),
    );
    final body = jsonDecode(await response.readAsString()) as Map;
    final results = body['results'] as List;

    final pullResponse = await handler(
      Request(
        'GET',
        Uri.parse('http://localhost/sync/pull'),
        headers: {'authorization': 'Bearer valid-token'},
      ),
    );
    final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
    final pulled = (pullBody['items'] as List).cast<Map>().single;

    expect(response.statusCode, 200);
    expect(results.map((item) => (item as Map)['status']), [
      'accepted',
      'accepted',
    ]);
    expect(pullResponse.statusCode, 200);
    expect(pulled['resourceType'], 'workoutTemplate');
    expect(pulled['clientId'], 'template-delete');
    expect(pulled['deletedAt'], '2026-07-19T10:31:00.000Z');
    expect(pulled['schemaVersion'], fixture.schemaVersion);
    expect(pulled['payload'], fixture.payload);
  });

  test('push applies LWW accepted, older ignored and equal ignored', () async {
    final repository = _FakeSyncedResourceRepository();
    final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
    final original = loadExerciseFixture('with_score_chrono');
    final stale = loadExerciseFixture('minimal');
    final newer = loadExerciseFixture('full_combo');

    final response = await handler(
      Request(
        'POST',
        Uri.parse('http://localhost/sync/push'),
        headers: {'authorization': 'Bearer valid-token'},
        body: jsonEncode({
          'deviceId': 'device-1',
          'items': [
            {
              'resourceType': 'exercise',
              'clientId': 'exercise-lww',
              'schemaVersion': original.schemaVersion,
              'clientUpdatedAt': '2026-07-19T10:00:00Z',
              'payload': original.payload,
            },
            {
              'resourceType': 'exercise',
              'clientId': 'exercise-lww',
              'schemaVersion': stale.schemaVersion,
              'clientUpdatedAt': '2026-07-19T09:59:59Z',
              'payload': stale.payload,
            },
            {
              'resourceType': 'exercise',
              'clientId': 'exercise-lww',
              'schemaVersion': stale.schemaVersion,
              'clientUpdatedAt': '2026-07-19T10:00:00Z',
              'payload': stale.payload,
            },
            {
              'resourceType': 'exercise',
              'clientId': 'exercise-lww',
              'schemaVersion': newer.schemaVersion,
              'clientUpdatedAt': '2026-07-19T10:00:01Z',
              'payload': newer.payload,
            },
          ],
        }),
      ),
    );

    final body = jsonDecode(await response.readAsString()) as Map;
    final results = body['results'] as List;
    final stored = repository.items.single;

    expect(response.statusCode, 200);
    expect(results.map((item) => (item as Map)['status']), [
      'accepted',
      'ignoredOlder',
      'ignoredOlder',
      'accepted',
    ]);
    expect(stored.schemaVersion, newer.schemaVersion);
    expect(stored.payloadJson, newer.payload);
  });

  test('pull returns synced items and filters them with since query', () async {
    final repository = _FakeSyncedResourceRepository()
      ..items.addAll([
        SyncedResource(
          serverId: 'resource-1',
          ownerUserId: 'user-1',
          resourceType: SyncedResourceType.exercise,
          clientId: 'exercise-1',
          payloadJson: {'name': 'Squat'},
          schemaVersion: 1,
          clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
          serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
        ),
        SyncedResource(
          serverId: 'resource-2',
          ownerUserId: 'user-1',
          resourceType: SyncedResourceType.program,
          clientId: 'program-1',
          payloadJson: {'name': 'Programme A'},
          schemaVersion: 2,
          clientUpdatedAt: DateTime.utc(2026, 7, 19, 11),
          serverUpdatedAt: DateTime.utc(2026, 7, 19, 12, 30),
        ),
      ]);
    final handler = buildApiHandler(syncApi: _syncApi(resources: repository));

    final response = await handler(
      Request(
        'GET',
        Uri.parse('http://localhost/sync/pull?since=2026-07-19T12:00:00Z'),
        headers: {'authorization': 'Bearer valid-token'},
      ),
    );

    final body = jsonDecode(await response.readAsString()) as Map;
    final items = body['items'] as List;

    expect(response.statusCode, 200);
    expect(body['serverCursor'], '2026-07-19T12:30:00.000Z');
    expect(items, hasLength(1));
    expect((items.single as Map)['clientId'], 'program-1');
    expect((items.single as Map)['resourceType'], 'program');
  });

  test('pull returns 400 for invalid since query parameter', () async {
    final handler = buildApiHandler(syncApi: _syncApi());

    final response = await handler(
      Request(
        'GET',
        Uri.parse('http://localhost/sync/pull?since=not-a-date'),
        headers: {'authorization': 'Bearer valid-token'},
      ),
    );

    expect(response.statusCode, 400);
    expect(jsonDecode(await response.readAsString()), {
      'error': 'Invalid date format',
    });
  });

  test('push returns 400 when request body is not a json object', () async {
    final handler = buildApiHandler(syncApi: _syncApi());

    final response = await handler(
      Request(
        'POST',
        Uri.parse('http://localhost/sync/push'),
        headers: {'authorization': 'Bearer valid-token'},
        body: jsonEncode(['not-an-object']),
      ),
    );

    expect(response.statusCode, 400);
    expect(jsonDecode(await response.readAsString()), {
      'error': 'Request body must be a JSON object.',
    });
  });

  test(
    'push rejects blank device id per item without storing payloads',
    () async {
      final repository = _FakeSyncedResourceRepository();
      final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
      final fixture = loadExerciseFixture('minimal');

      final response = await handler(
        Request(
          'POST',
          Uri.parse('http://localhost/sync/push'),
          headers: {'authorization': 'Bearer valid-token'},
          body: jsonEncode({
            'deviceId': '   ',
            'items': [
              {
                'resourceType': 'exercise',
                'clientId': 'exercise-blank-device',
                'schemaVersion': fixture.schemaVersion,
                'clientUpdatedAt': '2026-07-19T10:00:00Z',
                'payload': fixture.payload,
              },
            ],
          }),
        ),
      );

      final body = jsonDecode(await response.readAsString()) as Map;
      final results = body['results'] as List;

      expect(response.statusCode, 200);
      expect((results.single as Map)['status'], 'error');
      expect((results.single as Map)['message'], 'deviceId must not be blank.');
      expect(repository.items, isEmpty);
    },
  );

  test('exchange returns push results followed by pulled items', () async {
    final repository = _FakeSyncedResourceRepository()
      ..items.add(
        SyncedResource(
          serverId: 'existing-1',
          ownerUserId: 'user-1',
          resourceType: SyncedResourceType.workoutHistory,
          clientId: 'history-1',
          payloadJson: {'score': 12},
          schemaVersion: 1,
          clientUpdatedAt: DateTime.utc(2026, 7, 19, 8),
          serverUpdatedAt: DateTime.utc(2026, 7, 19, 9),
        ),
      );
    final handler = buildApiHandler(syncApi: _syncApi(resources: repository));

    final response = await handler(
      Request(
        'POST',
        Uri.parse('http://localhost/sync/exchange'),
        headers: {'authorization': 'Bearer valid-token'},
        body: jsonEncode({
          'deviceId': 'device-1',
          'since': '2026-07-19T08:30:00Z',
          'items': [
            {
              'resourceType': 'exercise',
              'clientId': 'exercise-2',
              'schemaVersion': 1,
              'clientUpdatedAt': '2026-07-19T10:00:00Z',
              'payload': {'name': 'Lunge'},
            },
          ],
        }),
      ),
    );

    final body = jsonDecode(await response.readAsString()) as Map;
    final pushResults = body['pushResults'] as List;
    final items = body['items'] as List;

    expect(response.statusCode, 200);
    expect(pushResults, hasLength(1));
    expect((pushResults.single as Map)['status'], 'accepted');
    expect(items, hasLength(2));
    expect(
      items.map((item) => (item as Map)['clientId']),
      containsAll(['history-1', 'exercise-2']),
    );
  });

  test('exchange returns 400 when items is not a json array', () async {
    final handler = buildApiHandler(syncApi: _syncApi());

    final response = await handler(
      Request(
        'POST',
        Uri.parse('http://localhost/sync/exchange'),
        headers: {'authorization': 'Bearer valid-token'},
        body: jsonEncode({'deviceId': 'device-1', 'items': 'not-a-list'}),
      ),
    );

    expect(response.statusCode, 400);
    expect(jsonDecode(await response.readAsString()), {
      'error': 'items must be a JSON array.',
    });
  });
}

SyncApi _syncApi({_FakeSyncedResourceRepository? resources}) {
  final repository = resources ?? _FakeSyncedResourceRepository();
  final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
  final ids = _FakeIds();
  final user = UserAccount(
    id: 'user-1',
    email: 'user@example.com',
    passwordHash: 'hash',
    createdAt: clock.now(),
    updatedAt: clock.now(),
  );
  final session = AuthSession(
    id: 'session-1',
    userId: user.id,
    tokenHash: 'token-hash:valid-token',
    issuedAt: clock.now(),
    expiresAt: clock.now().add(const Duration(days: 1)),
  );
  final users = _FakeUserRepository(user);
  final sessions = _FakeAuthSessionRepository(session);
  final tokens = _FakeTokenService();
  final authenticate = AuthenticateRequestUseCase(
    users: users,
    sessions: sessions,
    tokens: tokens,
    clock: clock,
  );
  final push = PushSyncUseCase(resources: repository, clock: clock, ids: ids);
  final pull = PullSyncUseCase(resources: repository, clock: clock);
  return SyncApi(
    pushSync: push,
    pullSync: pull,
    exchangeSync: ExchangeSyncUseCase(push: push, pull: pull),
    authenticateRequest: authenticate,
  );
}

final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
  final items = <SyncedResource>[];

  @override
  Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
    final existingIndex = items.indexWhere(
      (item) =>
          item.ownerUserId == resource.ownerUserId &&
          item.resourceType == resource.resourceType &&
          item.clientId == resource.clientId,
    );
    if (existingIndex != -1) {
      final existing = items[existingIndex];
      if (!resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) {
        return SyncWriteResult(
          status: SyncWriteStatus.ignoredOlder,
          resource: existing,
        );
      }
      final written = 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[existingIndex] = written;
      return SyncWriteResult(
        status: SyncWriteStatus.accepted,
        resource: written,
      );
    }
    items.add(resource);
    return SyncWriteResult(
      status: SyncWriteStatus.accepted,
      resource: resource,
    );
  }

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

final class _FakeUserRepository implements UserRepository {
  const _FakeUserRepository(this.user);

  final UserAccount user;

  @override
  Future<UserAccount?> findByEmail(String email) async => user;

  @override
  Future<UserAccount?> findById(String id) async => id == user.id ? user : null;

  @override
  Future<void> insert(UserAccount user) async {}

  @override
  Future<void> updatePasswordHash({
    required String userId,
    required String passwordHash,
    required DateTime updatedAt,
  }) async {}
}

final class _FakeAuthSessionRepository implements AuthSessionRepository {
  const _FakeAuthSessionRepository(this.session);

  final AuthSession session;

  @override
  Future<AuthSession?> findByTokenHash(String tokenHash) async {
    return tokenHash == session.tokenHash ? session : null;
  }

  @override
  Future<void> insert(AuthSession session) async {}

  @override
  Future<void> revoke({
    required String sessionId,
    required DateTime revokedAt,
  }) async {}
}

final class _FakeTokenService implements OpaqueTokenService {
  @override
  String generateToken() => 'valid-token';

  @override
  String hashToken(String token) => 'token-hash:$token';
}

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 'server-id-$_next';
  }
}
