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

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

import '../models/jenkins_build.dart';

class DownloadProgressEvent {
  const DownloadProgressEvent({
    required this.receivedBytes,
    this.totalBytes,
    this.isComplete = false,
    this.chunkBytes = const [],
  });

  final int receivedBytes;
  final int? totalBytes;
  final bool isComplete;

  /// Raw bytes delivered in this event. Empty on the final [isComplete] event.
  final List<int> chunkBytes;
}

class DownloadTask {
  DownloadTask._(this.events, this._cancel);

  /// Creates a [DownloadTask] from an existing event stream and a cancel callback.
  /// Used in tests and production wrappers where the stream is already constructed.
  DownloadTask.fromStream(
    Stream<DownloadProgressEvent> events, {
    void Function()? onCancel,
  }) : this._(events, onCancel ?? () {});

  final Stream<DownloadProgressEvent> events;
  final void Function() _cancel;

  void cancel() => _cancel();
}

class JenkinsClient {
  JenkinsClient({http.Client? client}) : _client = client ?? http.Client();

  final http.Client _client;

  Future<List<JenkinsJob>> fetchJobs({
    required Uri baseUrl,
    required JenkinsCredentials credentials,
  }) async {
    final uri = baseUrl.replace(
      path: _appendPath(baseUrl.path, 'api/json'),
      queryParameters: const {'tree': 'jobs[name,fullName,url,color,_class]'},
    );
    final response = await _client.get(uri, headers: _headers(credentials));

    _throwIfFailed(response);

    final json = jsonDecode(response.body) as Map<String, Object?>;
    final jobs = json['jobs'];
    if (jobs is! List) {
      return const [];
    }

    return jobs
        .whereType<Map<String, Object?>>()
        .map(JenkinsJob.fromJson)
        .toList();
  }

  Future<List<JenkinsBuild>> fetchRecentBuilds({
    required Uri jobUrl,
    required String jobName,
    required JenkinsCredentials credentials,
  }) async {
    final uri = jobUrl.replace(
      path: _appendPath(jobUrl.path, 'api/json'),
      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 response = await _client.get(uri, headers: _headers(credentials));

    _throwIfFailed(response);

    final json = jsonDecode(response.body) as Map<String, Object?>;
    final builds = json['builds'];
    if (builds is! List) {
      return const [];
    }

    return builds
        .whereType<Map<String, Object?>>()
        .map((build) => JenkinsBuild.fromJson(build, jobName: jobName))
        .where((build) => build.apkArtifacts.isNotEmpty)
        .toList();
  }

  Future<List<int>> downloadArtifact({
    required Uri artifactUrl,
    required JenkinsCredentials credentials,
  }) async {
    final response = await _client.get(
      artifactUrl,
      headers: _headers(credentials),
    );

    _throwIfFailed(response);

    return response.bodyBytes;
  }

  DownloadTask downloadArtifactStream({
    required Uri artifactUrl,
    required JenkinsCredentials credentials,
  }) {
    final controller = StreamController<DownloadProgressEvent>();
    var cancelled = false;
    StreamSubscription<List<int>>? subscription;

    controller.onListen = () async {
      try {
        final request = http.Request('GET', artifactUrl);
        request.headers.addAll(_headers(credentials));
        final streamed = await _client.send(request);

        if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
          final body = await streamed.stream.bytesToString();
          controller.addError(
            JenkinsClientException(
              statusCode: streamed.statusCode,
              message: body,
            ),
          );
          await controller.close();
          return;
        }

        final totalBytes = streamed.contentLength;
        var received = 0;

        subscription = streamed.stream.listen(
          (chunk) {
            if (cancelled) return;
            received += chunk.length;
            controller.add(
              DownloadProgressEvent(
                receivedBytes: received,
                totalBytes: totalBytes,
                chunkBytes: chunk,
              ),
            );
          },
          onDone: () {
            if (!cancelled && !controller.isClosed) {
              controller.add(
                DownloadProgressEvent(
                  receivedBytes: received,
                  totalBytes: totalBytes,
                  isComplete: true,
                ),
              );
              controller.close();
            }
          },
          onError: (Object e) {
            if (!cancelled && !controller.isClosed) {
              controller.addError(e);
              controller.close();
            }
          },
          cancelOnError: true,
        );
      } catch (e) {
        if (!controller.isClosed) {
          controller.addError(e);
          controller.close();
        }
      }
    };

    void cancel() {
      cancelled = true;
      subscription?.cancel();
      if (!controller.isClosed) {
        controller.close();
      }
    }

    return DownloadTask._(controller.stream, cancel);
  }

  Future<JenkinsUser> fetchCurrentUser({
    required Uri baseUrl,
    required JenkinsCredentials credentials,
  }) async {
    final uri = baseUrl.replace(
      path: _appendPath(baseUrl.path, 'whoAmI/api/json'),
    );
    final response = await _client.get(uri, headers: _headers(credentials));

    _throwIfFailed(response);

    final json = jsonDecode(response.body) as Map<String, Object?>;
    if (json['anonymous'] == true) {
      throw const JenkinsClientException(
        statusCode: 401,
        message: 'Jenkins whoAmI returned anonymous user',
      );
    }

    final id = (json['id'] as String?)?.trim() ?? '';
    if (id.isEmpty) {
      throw const JenkinsClientException(
        statusCode: 401,
        message: 'Jenkins whoAmI returned empty user id',
      );
    }

    final displayName = _firstNonBlank([
      json['fullName'] as String?,
      json['name'] as String?,
      id,
    ]);

    return JenkinsUser(
      id: id,
      displayName: displayName.isEmpty ? id : displayName,
    );
  }

  String _firstNonBlank(List<String?> values) {
    for (final value in values) {
      final trimmed = value?.trim();
      if (trimmed != null && trimmed.isNotEmpty) {
        return trimmed;
      }
    }

    return '';
  }

  Map<String, String> _headers(JenkinsCredentials credentials) {
    final raw = '${credentials.username}:${credentials.apiToken}';

    return {'Authorization': 'Basic ${base64Encode(utf8.encode(raw))}'};
  }

  void _throwIfFailed(http.Response response) {
    if (response.statusCode >= 200 && response.statusCode < 300) {
      return;
    }

    throw JenkinsClientException(
      statusCode: response.statusCode,
      message: response.body,
    );
  }

  String _appendPath(String path, String tail) {
    if (path.endsWith('/')) {
      return '$path$tail';
    }

    return '$path/$tail';
  }
}

class JenkinsCredentials {
  const JenkinsCredentials({required this.username, required this.apiToken});

  final String username;
  final String apiToken;
}

class JenkinsUser {
  const JenkinsUser({required this.id, required this.displayName});

  final String id;
  final String displayName;
}

class JenkinsClientException implements Exception {
  const JenkinsClientException({
    required this.statusCode,
    required this.message,
  });

  final int statusCode;
  final String message;

  @override
  String toString() =>
      'JenkinsClientException($statusCode): ${_redactSensitiveText(message)}';
}

String _redactSensitiveText(String value) {
  const marker = '[redacted]';
  var redacted = value;

  redacted = redacted.replaceAllMapped(
    RegExp(
      r'(authorization\s*[:=]\s*basic\s+)[A-Za-z0-9+/_=-]+',
      caseSensitive: false,
    ),
    (match) => '${match[1]}$marker',
  );

  redacted = redacted.replaceAllMapped(
    RegExp(
      r'''(\b(?:api[_-]?token|token|password|jenkins-crumb|crumb)\b\s*[:=]\s*)(["']?)[^"'\s,;&]+(["']?)''',
      caseSensitive: false,
    ),
    (match) => '${match[1]}${match[2]}$marker${match[3]}',
  );

  redacted = redacted.replaceAllMapped(
    RegExp(
      r'([?&](?:api[_-]?token|token|password|jenkins-crumb|crumb)=)[^&\s]+',
      caseSensitive: false,
    ),
    (match) => '${match[1]}$marker',
  );

  return redacted;
}
