// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only

/// Loads optional FocusFlow runtime configuration from a local JSON file.
library;

import 'dart:convert';
import 'dart:io';

import 'package:scheduler_core/scheduler_core.dart'
    show CivilDate, FocusFlowLogLevel;
import 'package:scheduler_core/scheduler_core.dart' show logger;

export 'package:scheduler_core/scheduler_core.dart' show FocusFlowLogLevel;

/// File-backed runtime configuration for optional developer diagnostics.
final class FocusFlowRuntimeConfig {
  /// Creates a runtime configuration with optional UI and logging controls.
  const FocusFlowRuntimeConfig({
    this.logLevel,
    this.logFileLocation,
    this.timeZone,
  });

  /// Loads runtime configuration from [path] or the configured default path.
  ///
  /// [now] is evaluated once and used to resolve DST-sensitive time-zone codes
  /// into one fixed offset for this app session.
  static Future<FocusFlowRuntimeConfig> load({
    String? path,
    DateTime Function()? now,
  }) async {
    final configPath = path ?? configPathFromEnvironment();
    final file = File(_expandHome(configPath));
    if (!await file.exists()) {
      logger.finer(() => 'Runtime config file not found. path=${file.path}');
      return const FocusFlowRuntimeConfig();
    }

    try {
      logger.debug(() => 'Loading runtime config. path=${file.path}');
      final decoded = jsonDecode(await file.readAsString());
      if (decoded is Map<String, Object?>) {
        final config = fromJson(
          decoded,
          referenceInstant: (now ?? DateTime.now).call().toUtc(),
        );
        logger.debug(
          () =>
              'Runtime config loaded. hasLogLevel=${config.logLevel != null} '
              'hasLogFileLocation=${config.logFileLocation != null} '
              'hasTimeZone=${config.timeZone != null}',
        );
        return config;
      }
      logger.warn(
        () =>
            'Runtime config ignored because decoded JSON was not an object. '
            'path=${file.path} decodedType=${decoded.runtimeType}',
      );
    } on Object catch (error, stackTrace) {
      logger.warn(
        () => 'Runtime config unreadable; using defaults. path=${file.path}',
        error: error,
        stackTrace: stackTrace,
      );
      return const FocusFlowRuntimeConfig();
    }

    return const FocusFlowRuntimeConfig();
  }

  /// Resolves the config file path from a Dart define or the local default.
  static String configPathFromEnvironment() {
    const configured = String.fromEnvironment('FOCUS_FLOW_CONFIG_PATH');
    final trimmed = configured.trim();
    if (trimmed.isNotEmpty) {
      logger.finer(
        () => 'Runtime config path resolved from environment. path=$trimmed',
      );
      return trimmed;
    }
    final path = defaultConfigPath();
    logger.finer(() => 'Runtime config path resolved from default. path=$path');
    return path;
  }

  /// Returns the default config file path.
  static String defaultConfigPath() {
    return _joinPath(_schedulerHomePath(), 'config.json');
  }

  /// Builds a runtime configuration from decoded JSON fields.
  ///
  /// [referenceInstant] resolves DST-sensitive time zones once while reading the
  /// config so normal app conversions can use the resulting fixed offset.
  static FocusFlowRuntimeConfig fromJson(
    Map<String, Object?> json, {
    DateTime? referenceInstant,
  }) {
    final level = FocusFlowLogLevel.tryParse(_optionalString(json['LogLevel']));
    final logLocation = _optionalString(json['LogfileLocation']);
    final timeZone = FocusFlowUiTimeZone.tryParse(
      _optionalString(json['Timezone'] ?? json['TimeZone'] ?? json['timezone']),
      referenceInstant: referenceInstant,
    );
    return FocusFlowRuntimeConfig(
      logLevel: level,
      logFileLocation: logLocation == null ? null : _expandHome(logLocation),
      timeZone: timeZone,
    );
  }

  /// Optional minimum level for file logging.
  final FocusFlowLogLevel? logLevel;

  /// Optional directory where the app should append its debug log file.
  final String? logFileLocation;

  /// Optional UI-local time-zone reference for wall-clock display and actions.
  final FocusFlowUiTimeZone? timeZone;

  /// Whether this configuration has enough settings to enable file logging.
  bool get enablesFileLogging => logLevel != null && logFileLocation != null;
}

/// UI-local fixed-offset time-zone reference loaded from runtime config.
final class FocusFlowUiTimeZone {
  /// Creates a UI time-zone reference from a normalized code and UTC offset.
  const FocusFlowUiTimeZone._({required this.code, required this.utcOffset});

  /// Default UI time-zone when no valid config value is provided.
  static const utc = FocusFlowUiTimeZone._(
    code: 'UTC',
    utcOffset: Duration.zero,
  );

  /// Parses a three-letter time-zone code without regard to character case.
  ///
  /// [referenceInstant] resolves supported daylight-saving aliases, such as
  /// `PST`/`PDT`, into the currently active code and offset up front.
  static FocusFlowUiTimeZone? tryParse(
    String? value, {
    DateTime? referenceInstant,
  }) {
    if (value == null) {
      return null;
    }
    final code = value.trim().toUpperCase();
    if (!_threeLetterTimeZoneCode.hasMatch(code)) {
      return null;
    }
    final resolvedCode = _resolveDaylightSavingCode(
      code,
      referenceInstant ?? DateTime.now().toUtc(),
    );
    final offsetMinutes = _timeZoneOffsetMinutesByCode[resolvedCode];
    if (offsetMinutes == null) {
      return null;
    }
    return FocusFlowUiTimeZone._(
      code: resolvedCode,
      utcOffset: Duration(minutes: offsetMinutes),
    );
  }

  /// Normalized three-letter time-zone code.
  final String code;

  /// Fixed offset from UTC used for UI-local wall-clock interpretation.
  final Duration utcOffset;

  /// Converts [instant] into a UI-local display `DateTime`.
  DateTime toLocalDateTime(DateTime instant) {
    return instant.toUtc().add(utcOffset);
  }

  /// Converts [instant] into the UI-local civil date.
  CivilDate toLocalCivilDate(DateTime instant) {
    return CivilDate.fromDateTime(toLocalDateTime(instant));
  }
}

/// Accepted time-zone code shape for manual UI config values.
final _threeLetterTimeZoneCode = RegExp(r'^[A-Z]{3}$');

/// Number of days in one calendar week.
const _daysPerWeek = 7;

/// Rule family for North American zones with standard/daylight abbreviations.
const _northAmericaDstFamily = 'northAmerica';

/// Rule family for British Summer Time.
const _britishDstFamily = 'british';

/// DST-linked code groups that should resolve to the active seasonal code.
const _daylightSavingTimeZoneRules =
    <
      String,
      ({
        String standardCode,
        String daylightCode,
        int standardOffsetMinutes,
        int daylightOffsetMinutes,
        String family,
      })
    >{
      'AST': (
        standardCode: 'AST',
        daylightCode: 'ADT',
        standardOffsetMinutes: -4 * 60,
        daylightOffsetMinutes: -3 * 60,
        family: _northAmericaDstFamily,
      ),
      'ADT': (
        standardCode: 'AST',
        daylightCode: 'ADT',
        standardOffsetMinutes: -4 * 60,
        daylightOffsetMinutes: -3 * 60,
        family: _northAmericaDstFamily,
      ),
      'CST': (
        standardCode: 'CST',
        daylightCode: 'CDT',
        standardOffsetMinutes: -6 * 60,
        daylightOffsetMinutes: -5 * 60,
        family: _northAmericaDstFamily,
      ),
      'CDT': (
        standardCode: 'CST',
        daylightCode: 'CDT',
        standardOffsetMinutes: -6 * 60,
        daylightOffsetMinutes: -5 * 60,
        family: _northAmericaDstFamily,
      ),
      'EST': (
        standardCode: 'EST',
        daylightCode: 'EDT',
        standardOffsetMinutes: -5 * 60,
        daylightOffsetMinutes: -4 * 60,
        family: _northAmericaDstFamily,
      ),
      'EDT': (
        standardCode: 'EST',
        daylightCode: 'EDT',
        standardOffsetMinutes: -5 * 60,
        daylightOffsetMinutes: -4 * 60,
        family: _northAmericaDstFamily,
      ),
      'MST': (
        standardCode: 'MST',
        daylightCode: 'MDT',
        standardOffsetMinutes: -7 * 60,
        daylightOffsetMinutes: -6 * 60,
        family: _northAmericaDstFamily,
      ),
      'MDT': (
        standardCode: 'MST',
        daylightCode: 'MDT',
        standardOffsetMinutes: -7 * 60,
        daylightOffsetMinutes: -6 * 60,
        family: _northAmericaDstFamily,
      ),
      'NST': (
        standardCode: 'NST',
        daylightCode: 'NDT',
        standardOffsetMinutes: -(3 * 60 + 30),
        daylightOffsetMinutes: -(2 * 60 + 30),
        family: _northAmericaDstFamily,
      ),
      'NDT': (
        standardCode: 'NST',
        daylightCode: 'NDT',
        standardOffsetMinutes: -(3 * 60 + 30),
        daylightOffsetMinutes: -(2 * 60 + 30),
        family: _northAmericaDstFamily,
      ),
      'PST': (
        standardCode: 'PST',
        daylightCode: 'PDT',
        standardOffsetMinutes: -8 * 60,
        daylightOffsetMinutes: -7 * 60,
        family: _northAmericaDstFamily,
      ),
      'PDT': (
        standardCode: 'PST',
        daylightCode: 'PDT',
        standardOffsetMinutes: -8 * 60,
        daylightOffsetMinutes: -7 * 60,
        family: _northAmericaDstFamily,
      ),
      'BST': (
        standardCode: 'GMT',
        daylightCode: 'BST',
        standardOffsetMinutes: 0,
        daylightOffsetMinutes: 60,
        family: _britishDstFamily,
      ),
    };

/// Fixed UTC offsets, in minutes, for recognized three-letter zone codes.
const _timeZoneOffsetMinutesByCode = <String, int>{
  'ADT': -3 * 60,
  'AFT': 4 * 60 + 30,
  'AST': -4 * 60,
  'BDT': 6 * 60,
  'BST': 1 * 60,
  'CAT': 2 * 60,
  'CET': 1 * 60,
  'CDT': -5 * 60,
  'CST': -6 * 60,
  'EAT': 3 * 60,
  'EDT': -4 * 60,
  'EET': 2 * 60,
  'EST': -5 * 60,
  'GMT': 0,
  'GST': 4 * 60,
  'HDT': -9 * 60,
  'HKT': 8 * 60,
  'HST': -10 * 60,
  'ICT': 7 * 60,
  'IST': 5 * 60 + 30,
  'JST': 9 * 60,
  'KST': 9 * 60,
  'MDT': -6 * 60,
  'MMT': 6 * 60 + 30,
  'MSK': 3 * 60,
  'MST': -7 * 60,
  'NDT': -(2 * 60 + 30),
  'NPT': 5 * 60 + 45,
  'NST': -(3 * 60 + 30),
  'PDT': -7 * 60,
  'PHT': 8 * 60,
  'PKT': 5 * 60,
  'PST': -8 * 60,
  'SGT': 8 * 60,
  'TRT': 3 * 60,
  'UCT': 0,
  'UTC': 0,
  'WAT': 1 * 60,
  'WET': 0,
  'WIB': 7 * 60,
  'WIT': 9 * 60,
};

/// Resolves [code] to the active seasonal code for DST-linked zones.
String _resolveDaylightSavingCode(String code, DateTime referenceInstant) {
  final rule = _daylightSavingTimeZoneRules[code];
  if (rule == null) {
    return code;
  }
  final instant = referenceInstant.toUtc();
  final usesDaylight = _usesDaylightSavingTime(rule, instant);
  return usesDaylight ? rule.daylightCode : rule.standardCode;
}

/// Reports whether [rule] is in daylight-saving time at [instant].
bool _usesDaylightSavingTime(
  ({
    String standardCode,
    String daylightCode,
    int standardOffsetMinutes,
    int daylightOffsetMinutes,
    String family,
  })
  rule,
  DateTime instant,
) {
  return switch (rule.family) {
    _northAmericaDstFamily => _isNorthAmericaDaylightSavingTime(
      instant: instant,
      standardOffsetMinutes: rule.standardOffsetMinutes,
      daylightOffsetMinutes: rule.daylightOffsetMinutes,
    ),
    _britishDstFamily => _isBritishSummerTime(instant),
    _ => false,
  };
}

/// Reports whether [instant] falls inside North American daylight saving time.
bool _isNorthAmericaDaylightSavingTime({
  required DateTime instant,
  required int standardOffsetMinutes,
  required int daylightOffsetMinutes,
}) {
  final utc = instant.toUtc();
  final year = utc.year;
  final startDay = _nthWeekdayOfMonth(
    year: year,
    month: DateTime.march,
    weekday: DateTime.sunday,
    occurrence: 2,
  );
  final endDay = _nthWeekdayOfMonth(
    year: year,
    month: DateTime.november,
    weekday: DateTime.sunday,
    occurrence: 1,
  );
  final startsAtUtc = DateTime.utc(
    year,
    DateTime.march,
    startDay,
    2,
  ).subtract(Duration(minutes: standardOffsetMinutes));
  final endsAtUtc = DateTime.utc(
    year,
    DateTime.november,
    endDay,
    2,
  ).subtract(Duration(minutes: daylightOffsetMinutes));
  return !utc.isBefore(startsAtUtc) && utc.isBefore(endsAtUtc);
}

/// Reports whether [instant] falls inside British Summer Time.
bool _isBritishSummerTime(DateTime instant) {
  final utc = instant.toUtc();
  final year = utc.year;
  final startDay = _lastWeekdayOfMonth(
    year: year,
    month: DateTime.march,
    weekday: DateTime.sunday,
  );
  final endDay = _lastWeekdayOfMonth(
    year: year,
    month: DateTime.october,
    weekday: DateTime.sunday,
  );
  final startsAtUtc = DateTime.utc(year, DateTime.march, startDay, 1);
  final endsAtUtc = DateTime.utc(year, DateTime.october, endDay, 1);
  return !utc.isBefore(startsAtUtc) && utc.isBefore(endsAtUtc);
}

/// Returns the day number for the [occurrence] of [weekday] in a month.
int _nthWeekdayOfMonth({
  required int year,
  required int month,
  required int weekday,
  required int occurrence,
}) {
  final firstDay = DateTime.utc(year, month);
  final daysUntilWeekday = (weekday - firstDay.weekday) % _daysPerWeek;
  return firstDay
      .add(Duration(days: daysUntilWeekday + (_daysPerWeek * (occurrence - 1))))
      .day;
}

/// Returns the day number for the last [weekday] in a month.
int _lastWeekdayOfMonth({
  required int year,
  required int month,
  required int weekday,
}) {
  final lastDay = DateTime.utc(year, month + 1, 0);
  final daysSinceWeekday = (lastDay.weekday - weekday) % _daysPerWeek;
  return lastDay.subtract(Duration(days: daysSinceWeekday)).day;
}

/// Reads [value] as a non-empty string when present.
String? _optionalString(Object? value) {
  if (value is! String) {
    return null;
  }
  final trimmed = value.trim();
  return trimmed.isEmpty ? null : trimmed;
}

/// Returns the local scheduler home directory path.
String _schedulerHomePath() {
  final configured = Platform.environment['SCHEDULER_HOME']?.trim();
  if (configured != null && configured.isNotEmpty) {
    return _expandHome(configured);
  }
  final home = _homePath();
  if (home == null) {
    return 'ADHD_Scheduler';
  }
  return _joinPath(home, 'ADHD_Scheduler');
}

/// Resolves the current user's home directory when the platform exposes it.
String? _homePath() {
  final home = Platform.environment['HOME']?.trim();
  if (home != null && home.isNotEmpty) {
    return home;
  }
  final userProfile = Platform.environment['USERPROFILE']?.trim();
  if (userProfile != null && userProfile.isNotEmpty) {
    return userProfile;
  }
  return null;
}

/// Expands a leading `~` in [path] using the platform home directory.
String _expandHome(String path) {
  if (path == '~') {
    return _homePath() ?? path;
  }
  if (path.startsWith('~/') || path.startsWith(r'~\')) {
    final home = _homePath();
    if (home == null) {
      return path;
    }
    return _joinPath(home, path.substring(2));
  }
  return path;
}

/// Joins [base] and [child] with the platform path separator.
String _joinPath(String base, String child) {
  if (base.endsWith('/') || base.endsWith(r'\')) {
    return '$base$child';
  }
  return '$base${Platform.pathSeparator}$child';
}
