/* 
 * This file is part of Stack Wallet.
 * 
 * Copyright (c) 2023 Cypher Stack
 * All Rights Reserved.
 * The code is distributed under GPLv3 license, see LICENSE file for details.
 * Generated by Cypher Stack on 2023-05-26
 *
 */

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

import 'package:archive/archive_io.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';

import '../app_config.dart';
import '../db/isar/main_db.dart';
import '../models/isar/stack_theme.dart';
import '../networking/http.dart';
import '../services/tor_service.dart';
import '../utilities/logger.dart';
import '../utilities/prefs.dart';
import '../utilities/stack_file_system.dart';

final pThemeService = Provider<ThemeService>((ref) {
  return ThemeService.instance;
});

class ThemeService {
  // dumb quick conditional based on name. Should really be done better
  static const _currentDefaultThemeVersion =
      AppConfig.appName == "Campfire" ? 17 : 15;
  ThemeService._();
  static ThemeService? _instance;
  static ThemeService get instance => _instance ??= ThemeService._();

  static const String baseServerUrl = "https://themes.stackwallet.com";

  MainDB? _db;
  MainDB get db => _db!;

  void init(MainDB db) => _db ??= db;

  HTTP client = HTTP();

  Future<void> install({required Uint8List themeArchiveData}) async {
    final themesDir = StackFileSystem.themesDir!;

    final archive = ZipDecoder().decodeBytes(themeArchiveData);

    final themeJsonFiles = archive.files.where((e) => e.name == "theme.json");

    if (themeJsonFiles.length != 1) {
      throw Exception("Invalid theme archive: Missing theme.json");
    }

    final jsonString = utf8.decode(themeJsonFiles.first.content as List<int>);
    final json = jsonDecode(jsonString) as Map;

    final theme = StackTheme.fromJson(
      json: Map<String, dynamic>.from(json),
    );

    try {
      theme.assets;
    } catch (_) {
      throw Exception("Invalid theme: Failed to create assets object");
    }

    final String assetsPath = "${themesDir.path}/${theme.themeId}";

    for (final file in archive.files) {
      if (file.isFile) {
        // TODO more sanitation?
        if (file.name.contains("..")) {
          Logging.instance.log(
            "Bad theme asset file path: ${file.name}",
            level: LogLevel.Error,
          );
        } else {
          final os = OutputFileStream("$assetsPath/${file.name}");
          file.writeContent(os);
          await os.close();
        }
      }
    }

    await db.isar.writeTxn(() async {
      await db.isar.stackThemes.put(theme);
    });
  }

  Future<void> remove({required String themeId}) async {
    final themesDir = StackFileSystem.themesDir!;
    final isarId = await db.isar.stackThemes
        .where()
        .themeIdEqualTo(themeId)
        .idProperty()
        .findFirst();
    if (isarId != null) {
      await db.isar.writeTxn(() async {
        await db.isar.stackThemes.delete(isarId);
      });
      final dir = Directory("${themesDir.path}/$themeId");
      if (dir.existsSync()) {
        await dir.delete(recursive: true);
      }
    } else {
      Logging.instance.log(
        "Failed to delete theme $themeId",
        level: LogLevel.Warning,
      );
    }
  }

  Future<void> checkDefaultThemesOnStartup() async {
    // install default themes
    if (!(await ThemeService.instance.verifyInstalled(themeId: "light"))) {
      Logging.instance.log(
        "Installing default light theme...",
        level: LogLevel.Info,
      );
      final lightZip = await rootBundle.load("assets/default_themes/light.zip");
      await ThemeService.instance
          .install(themeArchiveData: lightZip.buffer.asUint8List());
      Logging.instance.log(
        "Installing default light theme... finished",
        level: LogLevel.Info,
      );
    } else {
      // check installed version
      final theme = ThemeService.instance.getTheme(themeId: "light");
      if ((theme?.version ?? 1) < _currentDefaultThemeVersion) {
        Logging.instance.log(
          "Updating default light theme...",
          level: LogLevel.Info,
        );
        final lightZip =
            await rootBundle.load("assets/default_themes/light.zip");
        await ThemeService.instance
            .install(themeArchiveData: lightZip.buffer.asUint8List());
        Logging.instance.log(
          "Updating default light theme... finished",
          level: LogLevel.Info,
        );
      }
    }
    if (AppConfig.hasFeature(AppFeature.themeSelection)) {
      if (!(await ThemeService.instance.verifyInstalled(themeId: "dark"))) {
        Logging.instance.log(
          "Installing default dark theme... ",
          level: LogLevel.Info,
        );
        final darkZip = await rootBundle.load("assets/default_themes/dark.zip");
        await ThemeService.instance
            .install(themeArchiveData: darkZip.buffer.asUint8List());
        Logging.instance.log(
          "Installing default dark theme... finished",
          level: LogLevel.Info,
        );
      } else {
        // check installed version
        // final theme = ThemeService.instance.getTheme(themeId: "dark");
        // Force update theme to add missing icons for now
        // TODO: uncomment if statement in future when themes are version 4 or above
        // if ((theme?.version ?? 1) < _currentDefaultThemeVersion) {
        Logging.instance.log(
          "Updating default dark theme...",
          level: LogLevel.Info,
        );
        final darkZip = await rootBundle.load("assets/default_themes/dark.zip");
        await ThemeService.instance
            .install(themeArchiveData: darkZip.buffer.asUint8List());
        Logging.instance.log(
          "Updating default dark theme... finished",
          level: LogLevel.Info,
        );
        // }
      }
    }
  }

  // TODO more thorough check/verification of theme
  Future<bool> verifyInstalled({required String themeId}) async {
    final theme =
        await db.isar.stackThemes.where().themeIdEqualTo(themeId).findFirst();
    if (theme != null) {
      try {
        theme.assets;
      } catch (_) {
        return false;
      }

      final themesDir = StackFileSystem.themesDir!;
      final jsonFileExists =
          await File("${themesDir.path}/$themeId/theme.json").exists();
      final assetsDirExists =
          await Directory("${themesDir.path}/$themeId/assets").exists();

      if (!jsonFileExists || !assetsDirExists) {
        Logging.instance.log(
          "Theme $themeId found in DB but is missing files",
          level: LogLevel.Warning,
        );
      }

      return jsonFileExists && assetsDirExists;
    } else {
      return false;
    }
  }

  Future<List<StackThemeMetaData>> fetchThemes() async {
    try {
      final response = await client.get(
        url: Uri.parse("$baseServerUrl/themes"),
        proxyInfo: Prefs.instance.useTor
            ? TorService.sharedInstance.getProxyInfo()
            : null,
      );

      final jsonList = jsonDecode(response.body) as List;

      final result = List<Map<String, dynamic>>.from(jsonList)
          .map((e) => StackThemeMetaData.fromMap(e))
          .where((e) => e.id != "light" && e.id != "dark")
          .toList();

      return result;
    } catch (e, s) {
      Logging.instance.log(
        "Failed to fetch themes list: $e\n$s",
        level: LogLevel.Warning,
      );
      rethrow;
    }
  }

  Future<Uint8List> fetchTheme({
    required StackThemeMetaData themeMetaData,
  }) async {
    try {
      final response = await client.get(
        url: Uri.parse("$baseServerUrl/theme/${themeMetaData.id}"),
        proxyInfo: Prefs.instance.useTor
            ? TorService.sharedInstance.getProxyInfo()
            : null,
      );

      final bytes = Uint8List.fromList(response.bodyBytes);

      // verify hash
      final digest = sha256.convert(bytes);
      if (digest.toString() == themeMetaData.sha256) {
        return bytes;
      } else {
        throw Exception(
          "Fetched theme archive sha256 hash ($digest) does not"
          " match requested $themeMetaData",
        );
      }
    } catch (e, s) {
      Logging.instance.log(
        "Failed to fetch themes list: $e\n$s",
        level: LogLevel.Warning,
      );
      rethrow;
    }
  }

  StackTheme? getTheme({required String themeId}) =>
      db.isar.stackThemes.where().themeIdEqualTo(themeId).findFirstSync();

  List<StackTheme> get installedThemes =>
      db.isar.stackThemes.where().findAllSync();
}

class StackThemeMetaData {
  final String name;
  final String id;
  final int version;
  final String sha256;
  final String size;
  final String previewImageUrl;

  StackThemeMetaData({
    required this.name,
    required this.id,
    required this.version,
    required this.sha256,
    required this.size,
    required this.previewImageUrl,
  });

  static StackThemeMetaData fromMap(Map<String, dynamic> map) {
    try {
      return StackThemeMetaData(
        name: map["name"] as String,
        id: map["id"] as String,
        version: map["version"] as int? ?? 1,
        sha256: map["sha256"] as String,
        size: map["size"] as String,
        previewImageUrl: map["previewImageUrl"] as String,
      );
    } catch (e, s) {
      Logging.instance.log(
        "Failed to create instance of StackThemeMetaData using $map: \n$e\n$s",
        level: LogLevel.Fatal,
      );
      rethrow;
    }
  }

  @override
  String toString() {
    return "$runtimeType("
        "name: $name, "
        "id: $id, "
        "version: $version, "
        "sha256: $sha256, "
        "size: $size, "
        "previewImageUrl: $previewImageUrl"
        ")";
  }
}