// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/// `usage` is a wrapper around Google Analytics for both command-line apps
/// and web apps.
///
/// In order to use this library as a web app, import the `analytics_html.dart`
/// library and instantiate the [AnalyticsHtml] class.
///
/// In order to use this library as a command-line app, import the
/// `analytics_io.dart` library and instantiate the [AnalyticsIO] class.
///
/// For both classes, you need to provide a Google Analytics tracking ID, the
/// application name, and the application version.
///
/// Your application should provide an opt-in option for the user. If they
/// opt-in, set the [optIn] field to `true`. This setting will persist across
/// sessions automatically.
///
/// For more information, please see the Google Analytics Measurement Protocol
/// [Policy](https://developers.google.com/analytics/devguides/collection/protocol/policy).
library usage;

import 'dart:async';

// Matches file:/, non-ws, /, non-ws, .dart
final RegExp _pathRegex = RegExp(r'file:/\S+/(\S+\.dart)');

// Match multiple tabs or spaces.
final RegExp _tabOrSpaceRegex = RegExp(r'[\t ]+');

/// An interface to a Google Analytics session.
///
/// [AnalyticsHtml] and [AnalyticsIO] are concrete implementations of this
/// interface. [AnalyticsMock] can be used for testing or for some variants of
/// an opt-in workflow.
///
/// The analytics information is sent on a best-effort basis. So, failures to
/// send the GA information will not result in errors from the asynchronous
/// `send` methods.
abstract class Analytics {
  /// Tracking ID / Property ID.
  String get trackingId;

  /// The application name.
  String? get applicationName;

  /// The application version.
  String? get applicationVersion;

  /// Is this the first time the tool has run?
  bool get firstRun;

  /// Whether the [Analytics] instance is configured in an opt-in or opt-out
  /// manner.
  AnalyticsOpt analyticsOpt = AnalyticsOpt.optOut;

  /// Will analytics data be sent.
  bool get enabled;

  /// Enable or disable sending of analytics data.
  set enabled(bool value);

  /// Anonymous client ID in UUID v4 format.
  ///
  /// The value is randomly-generated and should be reasonably stable for the
  /// computer sending analytics data.
  String get clientId;

  /// Sends a screen view hit to Google Analytics.
  ///
  /// [parameters] can be any analytics key/value pair. Useful
  /// for custom dimensions, etc.
  Future<void> sendScreenView(String viewName,
      {Map<String, String>? parameters});

  /// Sends an Event hit to Google Analytics. [label] specifies the event label.
  /// [value] specifies the event value. Values must be non-negative.
  ///
  /// [parameters] can be any analytics key/value pair. Useful
  /// for custom dimensions, etc.
  Future<void> sendEvent(String category, String action,
      {String? label, int? value, Map<String, String>? parameters});

  /// Sends a Social hit to Google Analytics.
  ///
  /// [network] specifies the social network, for example Facebook or Google
  /// Plus. [action] specifies the social interaction action. For example on
  /// Google Plus when a user clicks the +1 button, the social action is 'plus'.
  /// [target] specifies the target of a
  /// social interaction. This value is typically a URL but can be any text.
  Future<void> sendSocial(String network, String action, String target);

  /// Sends a Timing hit to Google Analytics. [variableName] specifies the
  /// variable name of the timing. [time] specifies the user timing value (in
  /// milliseconds). [category] specifies the category of the timing. [label]
  /// specifies the label of the timing.
  Future<void> sendTiming(String variableName, int time,
      {String? category, String? label});

  /// Start a timer. The time won't be calculated, and the analytics information
  /// sent, until the [AnalyticsTimer.finish] method is called.
  AnalyticsTimer startTimer(String variableName,
      {String? category, String? label});

  /// In order to avoid sending any personally identifying information, the
  /// [description] field must not contain the exception message. In addition,
  /// only the first 100 chars of the description will be sent.
  Future<void> sendException(String description, {bool? fatal});

  /// Gets a session variable value.
  dynamic getSessionValue(String param);

  /// Sets a session variable value. The value is persistent for the life of the
  /// [Analytics] instance. This variable will be sent in with every analytics
  /// hit. A list of valid variable names can be found here:
  /// https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters.
  void setSessionValue(String param, dynamic value);

  /// Fires events when the usage library sends any data over the network. This
  /// will not fire if analytics has been disabled or if the throttling
  /// algorithm has been engaged.
  ///
  /// This method is public to allow library clients to more easily test their
  /// analytics implementations.
  Stream<Map<String, dynamic>> get onSend;

  /// Wait for all of the outstanding analytics pings to complete. The returned
  /// `Future` will always complete without errors. You can pass in an optional
  /// `Duration` to specify to only wait for a certain amount of time.
  ///
  /// This method is particularly useful for command-line clients. Outstanding
  /// I/O requests will cause the VM to delay terminating the process.
  /// Generally, users won't want their CLI app to pause at the end of the
  /// process waiting for Google analytics requests to complete. This method
  /// allows CLI apps to delay for a short time waiting for GA requests to
  /// complete, and then do something like call `dart:io`'s `exit()` explicitly
  /// themselves (or the [close] method below).
  Future<void> waitForLastPing({Duration? timeout});

  /// Free any used resources.
  ///
  /// The [Analytics] instance should not be used after this call.
  void close();
}

enum AnalyticsOpt {
  /// Users must opt-in before any analytics data is sent.
  optIn,

  /// Users must opt-out for analytics data to not be sent.
  optOut
}

/// An object, returned by [Analytics.startTimer], that is used to measure an
/// asynchronous process.
class AnalyticsTimer {
  final Analytics analytics;
  final String variableName;
  final String? category;
  final String? label;

  late final int _startMillis;
  int? _endMillis;

  AnalyticsTimer(this.analytics, this.variableName,
      {this.category, this.label}) {
    _startMillis = DateTime.now().millisecondsSinceEpoch;
  }

  int get currentElapsedMillis {
    if (_endMillis == null) {
      return DateTime.now().millisecondsSinceEpoch - _startMillis;
    } else {
      return _endMillis! - _startMillis;
    }
  }

  /// Finish the timer, calculate the elapsed time, and send the information to
  /// analytics. Once this is called, any future invocations are no-ops.
  Future<void> finish() {
    if (_endMillis != null) return Future.value();

    _endMillis = DateTime.now().millisecondsSinceEpoch;
    return analytics.sendTiming(variableName, currentElapsedMillis,
        category: category, label: label);
  }
}

/// A no-op implementation of the [Analytics] class. This can be used as a
/// stand-in for that will never ping the GA server, or as a mock in test code.
class AnalyticsMock implements Analytics {
  @override
  String get trackingId => 'UA-0';
  @override
  String get applicationName => 'mock-app';
  @override
  String get applicationVersion => '1.0.0';

  final bool logCalls;

  /// Events are never added to this controller for the mock implementation.
  final StreamController<Map<String, dynamic>> _sendController =
      StreamController.broadcast();

  /// Create a new [AnalyticsMock]. If [logCalls] is true, all calls will be
  /// logged to stdout.
  AnalyticsMock([this.logCalls = false]);

  @override
  bool get firstRun => false;

  @override
  AnalyticsOpt analyticsOpt = AnalyticsOpt.optOut;

  @override
  bool enabled = true;

  @override
  String get clientId => '00000000-0000-4000-0000-000000000000';

  @override
  Future<void> sendScreenView(String viewName,
      {Map<String, String>? parameters}) {
    parameters ??= <String, String>{};
    parameters['viewName'] = viewName;
    return _log('screenView', parameters);
  }

  @override
  Future<void> sendEvent(String category, String action,
      {String? label, int? value, Map<String, String>? parameters}) {
    parameters ??= <String, String>{};
    return _log(
        'event',
        {'category': category, 'action': action, 'label': label, 'value': value}
          ..addAll(parameters));
  }

  @override
  Future<void> sendSocial(String network, String action, String target) =>
      _log('social', {'network': network, 'action': action, 'target': target});

  @override
  Future<void> sendTiming(String variableName, int time,
      {String? category, String? label}) {
    return _log('timing', {
      'variableName': variableName,
      'time': time,
      'category': category,
      'label': label
    });
  }

  @override
  AnalyticsTimer startTimer(String variableName,
      {String? category, String? label}) {
    return AnalyticsTimer(this, variableName, category: category, label: label);
  }

  @override
  Future<void> sendException(String description, {bool? fatal}) =>
      _log('exception', {'description': description, 'fatal': fatal});

  @override
  dynamic getSessionValue(String param) => null;

  @override
  void setSessionValue(String param, dynamic value) {}

  @override
  Stream<Map<String, dynamic>> get onSend => _sendController.stream;

  @override
  Future<void> waitForLastPing({Duration? timeout}) => Future.value();

  @override
  void close() {}

  Future<void> _log(String hitType, Map<String, dynamic> m) {
    if (logCalls) {
      print('analytics: $hitType $m');
    }

    return Future.value();
  }
}

/// Sanitize a stacktrace. This will shorten file paths in order to remove any
/// PII that may be contained in the full file path. For example, this will
/// shorten `file:///Users/foobar/tmp/error.dart` to `error.dart`.
///
/// If [shorten] is `true`, this method will also attempt to compress the text
/// of the stacktrace. GA has a 100 char limit on the text that can be sent for
/// an exception. This will try and make those first 100 chars contain
/// information useful to debugging the issue.
String sanitizeStacktrace(dynamic st, {bool shorten = true}) {
  var str = '$st';

  Iterable<Match> iter = _pathRegex.allMatches(str);
  iter = iter.toList().reversed;

  for (var match in iter) {
    var replacement = match.group(1)!;
    str =
        str.substring(0, match.start) + replacement + str.substring(match.end);
  }

  if (shorten) {
    // Shorten the stacktrace up a bit.
    str = str.replaceAll(_tabOrSpaceRegex, ' ');
  }

  return str;
}
