import 'dart:async';
import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';

import 'package:appsok/src/models/jenkins_build.dart';
import 'package:appsok/src/services/jenkins_client.dart';

void main() {
  const credentials = JenkinsCredentials(username: 'user', apiToken: 'token');
  final baseUrl = Uri.parse('https://jenkins.example/');

  group('JenkinsJob.fromJson', () {
    test('parses jenkins jobs from api response', () {
      final json = {
        'name': 'my-app',
        'fullName': 'my-app',
        'url': 'https://jenkins.example/job/my-app/',
        'color': 'blue',
        '_class': 'hudson.model.FreeStyleProject',
      };

      final job = JenkinsJob.fromJson(json);

      expect(job.name, 'my-app');
      expect(job.fullName, 'my-app');
      expect(job.url, Uri.parse('https://jenkins.example/job/my-app/'));
      expect(job.color, 'blue');
      expect(job.isFolder, isFalse);
    });

    test('marks folder jobs from _class', () {
      final json = {
        'name': 'team-folder',
        'fullName': 'team-folder',
        'url': 'https://jenkins.example/job/team-folder/',
        '_class': 'com.cloudbees.hudson.plugins.folder.Folder',
      };

      final job = JenkinsJob.fromJson(json);

      expect(job.name, 'team-folder');
      expect(job.isFolder, isTrue);
      expect(job.color, isNull);
    });

    test('parses job with minimal fields', () {
      final json = {
        'name': 'minimal-job',
        'url': 'https://jenkins.example/job/minimal-job/',
      };

      final job = JenkinsJob.fromJson(json);

      expect(job.name, 'minimal-job');
      expect(job.fullName, isNull);
      expect(job.color, isNull);
      expect(job.isFolder, isFalse);
    });
  });

  group('JenkinsBuild.fromJson', () {
    test(
      'parses build metadata, requested user, parameters, and artifacts',
      () {
        final build = JenkinsBuild.fromJson({
          'number': 42,
          'url': 'https://jenkins.example/job/app/42/',
          'timestamp': 1760000000000,
          'result': 'SUCCESS',
          'actions': [
            {
              'causes': [
                {'userName': 'Ada Lovelace', 'userId': 'ada'},
              ],
              'parameters': [
                {'name': 'BRANCH_NAME', 'value': 'feature/install'},
                {'name': 'FLAVOR', 'value': 'qa'},
              ],
            },
          ],
          'artifacts': [
            {'fileName': 'app-qa.apk', 'relativePath': 'outputs/app-qa.apk'},
          ],
        }, jobName: 'android-app');

        expect(build.number, 42);
        expect(build.jobName, 'android-app');
        expect(build.result, 'SUCCESS');
        expect(build.isRunning, isFalse);
        expect(build.requestedBy, 'Ada Lovelace');
        expect(build.branch, 'feature/install');
        expect(build.flavor, 'qa');
        expect(build.artifacts.single.fileName, 'app-qa.apk');
        expect(build.apkArtifacts.single.relativePath, 'outputs/app-qa.apk');
      },
    );

    test('falls back from cause trigger source to commit author', () {
      final upstreamBuild = JenkinsBuild.fromJson({
        'number': 41,
        'url': 'https://jenkins.example/job/app/41/',
        'result': null,
        'actions': [
          {
            'causes': [
              {
                'shortDescription': 'Started by upstream project smoke',
                'upstreamProject': 'smoke',
                'upstreamBuild': 77,
              },
            ],
          },
        ],
        'changeSet': {
          'items': [
            {
              'author': {'fullName': 'Grace Hopper'},
            },
          ],
        },
        'artifacts': [],
      }, jobName: 'android-app');

      final commitBuild = JenkinsBuild.fromJson({
        'number': 40,
        'url': 'https://jenkins.example/job/app/40/',
        'result': 'FAILURE',
        'actions': [],
        'changeSet': {
          'items': [
            {
              'author': {'id': 'hopper'},
            },
          ],
        },
        'artifacts': [],
      }, jobName: 'android-app');

      expect(upstreamBuild.isRunning, isTrue);
      expect(upstreamBuild.requestedBy, 'Started by upstream project smoke');
      expect(commitBuild.requestedBy, 'hopper');
    });
  });

  group('BuildArtifact', () {
    test('detects apk artifacts case-insensitively', () {
      final artifact = BuildArtifact(
        fileName: 'APP-QA.APK',
        relativePath: 'outputs/app-qa.APK',
      );
      final mapping = BuildArtifact(
        fileName: 'mapping.txt',
        relativePath: 'outputs/mapping.txt',
      );

      expect(artifact.isApk, isTrue);
      expect(mapping.isApk, isFalse);
    });
  });

  group('JenkinsClient.fetchJobs', () {
    test('fetchJobs requests root api json with job tree', () async {
      Uri? capturedUri;
      Map<String, String>? capturedHeaders;

      final client = JenkinsClient(
        client: MockClient((request) async {
          capturedUri = request.url;
          capturedHeaders = request.headers;
          return http.Response(
            jsonEncode({
              'jobs': [
                {
                  'name': 'app-release',
                  'fullName': 'app-release',
                  'url': 'https://jenkins.example/job/app-release/',
                  'color': 'blue',
                  '_class': 'hudson.model.FreeStyleProject',
                },
              ],
            }),
            200,
          );
        }),
      );

      final jobs = await client.fetchJobs(
        baseUrl: baseUrl,
        credentials: credentials,
      );

      expect(jobs, hasLength(1));
      expect(jobs.first.name, 'app-release');
      expect(capturedUri?.path, contains('api/json'));
      expect(
        capturedUri?.queryParameters['tree'],
        'jobs[name,fullName,url,color,_class]',
      );

      final expectedAuth = 'Basic ${base64Encode(utf8.encode('user:token'))}';
      expect(capturedHeaders?['Authorization'], expectedAuth);
    });

    test('fetchJobs returns empty list when jobs key is missing', () async {
      final client = JenkinsClient(
        client: MockClient(
          (_) async =>
              http.Response(jsonEncode({'_class': 'hudson.model.Hudson'}), 200),
        ),
      );

      final jobs = await client.fetchJobs(
        baseUrl: baseUrl,
        credentials: credentials,
      );

      expect(jobs, isEmpty);
    });

    test('fetchJobs returns empty list when jobs is empty array', () async {
      final client = JenkinsClient(
        client: MockClient(
          (_) async => http.Response(jsonEncode({'jobs': []}), 200),
        ),
      );

      final jobs = await client.fetchJobs(
        baseUrl: baseUrl,
        credentials: credentials,
      );

      expect(jobs, isEmpty);
    });

    test(
      'fetchJobs throws JenkinsClientException on forbidden response',
      () async {
        final client = JenkinsClient(
          client: MockClient((_) async => http.Response('Forbidden', 403)),
        );

        expect(
          () => client.fetchJobs(baseUrl: baseUrl, credentials: credentials),
          throwsA(
            isA<JenkinsClientException>().having(
              (e) => e.statusCode,
              'statusCode',
              403,
            ),
          ),
        );
      },
    );
  });

  group('JenkinsClient.downloadArtifactStream', () {
    final artifactUrl = Uri.parse('https://jenkins.example/artifact/app.apk');

    test(
      'downloadArtifactStream emits progress while reading chunks',
      () async {
        final chunk1 = List<int>.filled(1024, 1);
        final chunk2 = List<int>.filled(512, 2);

        final client = JenkinsClient(
          client: MockClient.streaming((request, _) async {
            final controller = StreamController<List<int>>();
            scheduleMicrotask(() async {
              controller.add(chunk1);
              controller.add(chunk2);
              await controller.close();
            });
            return http.StreamedResponse(
              controller.stream,
              200,
              contentLength: chunk1.length + chunk2.length,
            );
          }),
        );

        final task = client.downloadArtifactStream(
          artifactUrl: artifactUrl,
          credentials: credentials,
        );

        final events = await task.events.toList();

        expect(events, hasLength(3));
        expect(events[0].receivedBytes, chunk1.length);
        expect(events[0].chunkBytes, chunk1);
        expect(events[0].isComplete, isFalse);
        expect(events[1].receivedBytes, chunk1.length + chunk2.length);
        expect(events[1].chunkBytes, chunk2);
        expect(events[1].isComplete, isFalse);
        expect(events[2].isComplete, isTrue);
        expect(events[2].chunkBytes, isEmpty);
        expect(events[2].receivedBytes, chunk1.length + chunk2.length);
        expect(events[2].totalBytes, chunk1.length + chunk2.length);
      },
    );

    test('downloadArtifactStream reports forbidden response', () async {
      final client = JenkinsClient(
        client: MockClient.streaming(
          (req, body) async => http.StreamedResponse(
            Stream.value(utf8.encode('Forbidden')),
            403,
          ),
        ),
      );

      final task = client.downloadArtifactStream(
        artifactUrl: artifactUrl,
        credentials: credentials,
      );

      expect(
        task.events.toList(),
        throwsA(
          isA<JenkinsClientException>().having(
            (e) => e.statusCode,
            'statusCode',
            403,
          ),
        ),
      );
    });

    test('downloadArtifactStream can be cancelled before completion', () async {
      final firstChunkCompleter = Completer<void>();

      final client = JenkinsClient(
        client: MockClient.streaming((request, _) async {
          final controller = StreamController<List<int>>();
          controller.onListen = () async {
            controller.add(List<int>.filled(512, 1));
            await firstChunkCompleter.future;
            if (!controller.isClosed) {
              controller.add(List<int>.filled(512, 2));
              await controller.close();
            }
          };
          return http.StreamedResponse(controller.stream, 200);
        }),
      );

      final task = client.downloadArtifactStream(
        artifactUrl: artifactUrl,
        credentials: credentials,
      );

      final received = <DownloadProgressEvent>[];
      final doneCompleter = Completer<void>();
      task.events.listen(
        (e) {
          received.add(e);
          if (received.length == 1) {
            task.cancel();
            firstChunkCompleter.complete();
          }
        },
        onDone: () => doneCompleter.complete(),
        onError: (_) => doneCompleter.complete(),
        cancelOnError: false,
      );

      await doneCompleter.future;

      expect(received, isNotEmpty);
      expect(received.any((e) => e.isComplete), isFalse);
    });
  });

  group('JenkinsClient.fetchCurrentUser', () {
    test('fetchCurrentUser requests whoAmI api with auth header', () async {
      Uri? capturedUri;
      Map<String, String>? capturedHeaders;

      final client = JenkinsClient(
        client: MockClient((request) async {
          capturedUri = request.url;
          capturedHeaders = request.headers;
          return http.Response(
            jsonEncode({'id': 'ada', 'fullName': 'Ada Lovelace'}),
            200,
          );
        }),
      );

      final user = await client.fetchCurrentUser(
        baseUrl: baseUrl,
        credentials: credentials,
      );

      expect(user.id, 'ada');
      expect(user.displayName, 'Ada Lovelace');
      expect(capturedUri?.path, contains('whoAmI/api/json'));

      final expectedAuth = 'Basic ${base64Encode(utf8.encode('user:token'))}';
      expect(capturedHeaders?['Authorization'], expectedAuth);
    });

    test('fetchCurrentUser parses id and display name with fallback', () async {
      final client = JenkinsClient(
        client: MockClient(
          (_) async =>
              http.Response(jsonEncode({'id': 'grace', 'fullName': ''}), 200),
        ),
      );

      final user = await client.fetchCurrentUser(
        baseUrl: baseUrl,
        credentials: credentials,
      );

      expect(user.id, 'grace');
      expect(user.displayName, 'grace');
    });

    test(
      'fetchCurrentUser falls back to name when fullName is blank',
      () async {
        final client = JenkinsClient(
          client: MockClient(
            (_) async => http.Response(
              jsonEncode({
                'id': 'grace',
                'fullName': '   ',
                'name': 'Grace Hopper',
              }),
              200,
            ),
          ),
        );

        final user = await client.fetchCurrentUser(
          baseUrl: baseUrl,
          credentials: credentials,
        );

        expect(user.id, 'grace');
        expect(user.displayName, 'Grace Hopper');
      },
    );

    test(
      'fetchCurrentUser throws JenkinsClientException on forbidden response',
      () async {
        final client = JenkinsClient(
          client: MockClient((_) async => http.Response('Forbidden', 403)),
        );

        expect(
          () => client.fetchCurrentUser(
            baseUrl: baseUrl,
            credentials: credentials,
          ),
          throwsA(
            isA<JenkinsClientException>().having(
              (e) => e.statusCode,
              'statusCode',
              403,
            ),
          ),
        );
      },
    );

    test('fetchCurrentUser rejects anonymous whoAmI response', () async {
      final client = JenkinsClient(
        client: MockClient(
          (_) async => http.Response(
            jsonEncode({
              'anonymous': true,
              'id': 'anonymous',
              'fullName': 'anonymous',
            }),
            200,
          ),
        ),
      );

      expect(
        () =>
            client.fetchCurrentUser(baseUrl: baseUrl, credentials: credentials),
        throwsA(
          isA<JenkinsClientException>().having(
            (e) => e.statusCode,
            'statusCode',
            401,
          ),
        ),
      );
    });

    test('fetchCurrentUser rejects blank whoAmI id', () async {
      final client = JenkinsClient(
        client: MockClient(
          (_) async =>
              http.Response(jsonEncode({'anonymous': false, 'id': '   '}), 200),
        ),
      );

      expect(
        () =>
            client.fetchCurrentUser(baseUrl: baseUrl, credentials: credentials),
        throwsA(
          isA<JenkinsClientException>().having(
            (e) => e.statusCode,
            'statusCode',
            401,
          ),
        ),
      );
    });
  });

  group('JenkinsClient.fetchRecentBuilds', () {
    test(
      'requests build tree and returns only builds with apk artifacts',
      () async {
        Uri? capturedUri;
        Map<String, String>? capturedHeaders;
        final jobUrl = Uri.parse('https://jenkins.example/job/android-app/');

        final client = JenkinsClient(
          client: MockClient((request) async {
            capturedUri = request.url;
            capturedHeaders = request.headers;
            return http.Response(
              jsonEncode({
                'builds': [
                  {
                    'number': 12,
                    'url': 'https://jenkins.example/job/android-app/12/',
                    'timestamp': 1760000000000,
                    'result': 'SUCCESS',
                    'actions': [
                      {
                        'causes': [
                          {'userName': 'Ada Lovelace', 'userId': 'ada'},
                        ],
                        'parameters': [
                          {'name': 'branch', 'value': 'main'},
                          {'name': 'flavor', 'value': 'qa'},
                        ],
                      },
                    ],
                    'artifacts': [
                      {
                        'fileName': 'android-app-qa.apk',
                        'relativePath': 'outputs/android-app-qa.apk',
                      },
                      {
                        'fileName': 'mapping.txt',
                        'relativePath': 'outputs/mapping.txt',
                      },
                    ],
                  },
                  {
                    'number': 11,
                    'url': 'https://jenkins.example/job/android-app/11/',
                    'timestamp': 1750000000000,
                    'result': 'FAILURE',
                    'actions': [],
                    'artifacts': [
                      {
                        'fileName': 'mapping.txt',
                        'relativePath': 'outputs/mapping.txt',
                      },
                    ],
                  },
                ],
              }),
              200,
            );
          }),
        );

        final builds = await client.fetchRecentBuilds(
          jobUrl: jobUrl,
          jobName: 'android-app',
          credentials: credentials,
        );

        expect(builds, hasLength(1));
        expect(builds.single.number, 12);
        expect(builds.single.requestedBy, 'Ada Lovelace');
        expect(builds.single.result, 'SUCCESS');
        expect(builds.single.branch, 'main');
        expect(builds.single.flavor, 'qa');
        expect(builds.single.artifacts, hasLength(2));
        expect(
          builds.single.apkArtifacts.single.fileName,
          'android-app-qa.apk',
        );
        expect(capturedUri?.path, '/job/android-app/api/json');
        expect(
          capturedUri?.queryParameters['tree'],
          'builds[number,url,timestamp,result,actions[parameters[name,value],causes[userName,userId,shortDescription,upstreamProject,upstreamBuild]],changeSet[items[author[fullName,id],authorEmail]],artifacts[fileName,relativePath]]',
        );

        final expectedAuth = 'Basic ${base64Encode(utf8.encode('user:token'))}';
        expect(capturedHeaders?['Authorization'], expectedAuth);
      },
    );
  });

  group('JenkinsClientException', () {
    test('toString redacts credential-like values', () {
      final apiToken = ['api', 'token', 'value'].join('-');
      final password = ['pass', 'word', 'value'].join('-');
      final basic = base64Encode(utf8.encode('user:$apiToken'));
      final exception = JenkinsClientException(
        statusCode: 500,
        message:
            'Authorization: Basic $basic apiToken=$apiToken '
            'password="$password" '
            'https://jenkins.example/me?token=$apiToken',
      );

      final text = exception.toString();

      expect(text, contains('JenkinsClientException(500)'));
      expect(text, contains('apiToken=[redacted]'));
      expect(text, contains('password="[redacted]"'));
      expect(text, contains('?token=[redacted]'));
      expect(text, isNot(contains(apiToken)));
      expect(text, isNot(contains(password)));
      expect(text, isNot(contains(basic)));
    });
  });
}
