import 'package:flutter/material.dart' hide Visibility;
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:result_monad/result_monad.dart';

import '../friendica_client/friendica_client.dart';
import '../friendica_client/paging_data.dart';
import '../globals.dart';
import '../models/TimelineIdentifiers.dart';
import '../models/auth/profile.dart';
import '../models/entry_tree_item.dart';
import '../models/exec_error.dart';
import '../models/image_entry.dart';
import '../models/media_attachment_uploads/new_entry_media_items.dart';
import '../models/timeline_entry.dart';
import '../models/visibility.dart';
import '../utils/active_profile_selector.dart';
import 'media_upload_attachment_helper.dart';
import 'reshared_via_service.dart';

class EntryManagerService extends ChangeNotifier {
  static final _logger = Logger('$EntryManagerService');
  final _entries = <String, TimelineEntry>{};
  final _parentPostIds = <String, String>{};
  final _postNodes = <String, _Node>{};
  final Profile profile;

  EntryManagerService(this.profile);

  void clear() {
    _entries.clear();
    _parentPostIds.clear();
    _postNodes.clear();
  }

  _Node? _getPostRootNode(String id) {
    final fromPosts = _postNodes[id];
    if (fromPosts != null) {
      return fromPosts;
    }

    final parentId = _parentPostIds[id];
    if (parentId == null) {
      return null;
    }

    return _postNodes[parentId];
  }

  Result<EntryTreeItem, ExecError> getPostTreeEntryBy(String id) {
    _logger.finest('Getting post: $id');
    final idForCall = id;
    final postNode = _getPostRootNode(idForCall);
    if (postNode == null) {
      return Result.error(ExecError(
        type: ErrorType.notFound,
        message: 'Unknown post id: $id',
      ));
    }

    return Result.ok(_nodeToTreeItem(postNode, profile.userId));
  }

  Result<TimelineEntry, ExecError> getEntryById(String id) {
    if (_entries.containsKey(id)) {
      return Result.ok(_entries[id]!);
    }

    return Result.error(ExecError(
      type: ErrorType.notFound,
      message: 'Timeline entry not found: $id',
    ));
  }

  FutureResult<bool, ExecError> deleteEntryById(String id) async {
    _logger.finest('Delete entry: $id');
    final result = await StatusesClient(profile).deleteEntryById(id);
    if (result.isFailure) {
      return result.errorCast();
    }

    _cleanupEntriesForId(id);
    notifyListeners();
    return Result.ok(true);
  }

  FutureResult<bool, ExecError> createNewStatus(
    String text, {
    String spoilerText = '',
    String inReplyToId = '',
    required NewEntryMediaItems mediaItems,
    required List<ImageEntry> existingMediaItems,
    required Visibility visibility,
  }) async {
    _logger.finest('Creating new post: $text');
    final mediaIds = existingMediaItems.map((m) => m.scales.first.id).toList();
    for (final item in mediaItems.attachments) {
      if (item.isExistingServerItem) {
        continue;
      }

      final String extension = p.extension(item.localFilePath);
      late final String filename;
      if (item.remoteFilename.isEmpty) {
        filename = p.basename(item.localFilePath);
      } else {
        if (item.remoteFilename
            .toLowerCase()
            .endsWith(extension.toLowerCase())) {
          filename = item.remoteFilename;
        } else {
          filename = "${item.remoteFilename}$extension";
        }
      }

      final uploadResult =
          await MediaUploadAttachmentHelper.getUploadableImageBytes(
        item.localFilePath,
      ).andThenAsync(
        (imageBytes) async =>
            await RemoteFileClient(profile).uploadFileAsAttachment(
          bytes: imageBytes,
          album: mediaItems.albumName,
          description: item.description,
          fileName: filename,
          visibility: visibility,
        ),
      );
      if (uploadResult.isSuccess) {
        mediaIds.add(uploadResult.value.scales.first.id);
      } else {
        return Result.error(ExecError(
            type: ErrorType.localError,
            message: 'Error uploading image: ${uploadResult.error}'));
      }
    }

    final result = await StatusesClient(profile)
        .createNewStatus(
            text: text,
            spoilerText: spoilerText,
            inReplyToId: inReplyToId,
            mediaIds: mediaIds,
            visibility: visibility)
        .andThenSuccessAsync((item) async {
      await processNewItems([item], profile.username, null);
      return item;
    }).andThenSuccessAsync((item) async {
      if (inReplyToId.isNotEmpty) {
        late final String rootPostId;
        if (_postNodes.containsKey(inReplyToId)) {
          rootPostId = inReplyToId;
        } else {
          rootPostId = _parentPostIds[inReplyToId]!;
        }
        await refreshStatusChain(rootPostId);
      }
      return item;
    });

    return result.mapValue((status) {
      _logger.finest('${status.id} status created');
      return true;
    }).mapError(
      (error) {
        _logger.finest('Error creating post: $error');
        return ExecError(
          type: ErrorType.localError,
          message: error.toString(),
        );
      },
    );
  }

  FutureResult<bool, ExecError> editStatus(
    String id,
    String text, {
    String spoilerText = '',
    required NewEntryMediaItems mediaItems,
    required List<ImageEntry> existingMediaItems,
    required Visibility newMediaItemVisibility,
  }) async {
    _logger.finest('Editing post: $text');
    final idForCall = id;
    final mediaIds = existingMediaItems
        .map((m) => m.scales.isEmpty ? m.id : m.scales.first.id)
        .toList();
    for (final item in mediaItems.attachments) {
      if (item.isExistingServerItem) {
        continue;
      }

      final String extension = p.extension(item.localFilePath);
      late final String filename;
      if (item.remoteFilename.isEmpty) {
        filename = p.basename(item.localFilePath);
      } else {
        if (item.remoteFilename
            .toLowerCase()
            .endsWith(extension.toLowerCase())) {
          filename = item.remoteFilename;
        } else {
          filename = "${item.remoteFilename}$extension";
        }
      }

      final uploadResult =
          await MediaUploadAttachmentHelper.getUploadableImageBytes(
        item.localFilePath,
      ).andThenAsync(
        (imageBytes) async => await RemoteFileClient(profile)
            .uploadFileAsAttachment(
                bytes: imageBytes,
                album: mediaItems.albumName,
                description: item.description,
                fileName: filename,
                visibility: newMediaItemVisibility),
      );
      if (uploadResult.isSuccess) {
        mediaIds.add(uploadResult.value.scales.first.id);
      } else {
        return Result.error(ExecError(
            type: ErrorType.localError,
            message: 'Error uploading image: ${uploadResult.error}'));
      }
    }

    final result = await StatusesClient(profile)
        .editStatus(
            id: idForCall,
            text: text,
            spoilerText: spoilerText,
            mediaIds: mediaIds)
        .andThenSuccessAsync((item) async {
      await processNewItems([item], profile.username, null);
      return item;
    }).andThenSuccessAsync((item) async {
      final inReplyToId = item.parentId;
      if (inReplyToId.isNotEmpty) {
        late final String rootPostId;
        if (_postNodes.containsKey(inReplyToId)) {
          rootPostId = inReplyToId;
        } else {
          rootPostId = _parentPostIds[inReplyToId]!;
        }
        await refreshStatusChain(rootPostId);
      }
      return item;
    });

    return result.mapValue((status) {
      _logger.finest('${status.id} status created');
      return true;
    }).mapError(
      (error) {
        _logger.finest('Error creating post: $error');
        return ExecError(
          type: ErrorType.localError,
          message: error.toString(),
        );
      },
    );
  }

  FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
      TimelineIdentifiers type, int maxId, int sinceId) async {
    _logger.fine(() => 'Updating timeline');
    final client = TimelineClient(profile);
    final itemsResult = await client.getTimeline(
      type: type,
      page: PagingData(
        maxId: maxId > 0 ? maxId : null,
        sinceId: sinceId > 0 ? sinceId : null,
      ),
    );
    if (itemsResult.isFailure) {
      _logger.severe('Error getting timeline: ${itemsResult.error}');
      return itemsResult.errorCast();
    }

    itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id));
    final updatedPosts =
        await processNewItems(itemsResult.value, profile.userId, client);
    _logger.finest(() {
      final postCount = _entries.values.where((e) => e.parentId.isEmpty).length;
      final commentCount = _entries.length - postCount;
      final orphanCount = _entries.values
          .where(
              (e) => e.parentId.isNotEmpty && !_entries.containsKey(e.parentId))
          .length;
      return 'End of update # posts: $postCount, #comments: $commentCount, #orphans: $orphanCount';
    });
    return Result.ok(updatedPosts);
  }

  Future<List<EntryTreeItem>> processNewItems(
    List<TimelineEntry> items,
    String currentId,
    FriendicaClient? client,
  ) async {
    items.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id)));
    final allSeenItems = [...items];
    for (final item in items) {
      _entries[item.id] = item;
    }

    final orphans = <TimelineEntry>[];
    for (final item in items) {
      if (item.parentId.isEmpty) {
        continue;
      }

      final parent = _entries[item.parentId];
      if (parent == null) {
        orphans.add(item);
      } else {
        if (parent.parentId.isEmpty) {
          _parentPostIds[item.id] = parent.id;
        }
      }
    }

    for (final o in orphans) {
      await StatusesClient(profile)
          .getPostOrComment(o.id, fullContext: true)
          .andThenSuccessAsync((items) async {
        final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id;
        _parentPostIds[o.id] = parentPostId;
        allSeenItems.addAll(items);
        for (final item in items) {
          _entries[item.id] = item;
          _parentPostIds[item.id] = parentPostId;
        }
      });
    }

    allSeenItems.sort((i1, i2) {
      if (i1.parentId.isEmpty && i2.parentId.isNotEmpty) {
        return -1;
      }

      if (i2.parentId.isEmpty && i1.parentId.isNotEmpty) {
        return 1;
      }

      return int.parse(i1.id).compareTo(int.parse(i2.id));
    });
    final postNodesToReturn = <_Node>{};
    var lastCount = 0;
    while (allSeenItems.isNotEmpty) {
      final seenItemsCopy = [...allSeenItems];
      for (final item in seenItemsCopy) {
        if (item.parentId.isEmpty) {
          final postNode =
              _postNodes.putIfAbsent(item.id, () => _Node(item.id));
          postNodesToReturn.add(postNode);
          allSeenItems.remove(item);
        } else {
          final parentParentPostId = _postNodes.containsKey(item.parentId)
              ? item.parentId
              : _parentPostIds[item.parentId];
          if (_postNodes[parentParentPostId] == null) {
            _logger.severe(
                'Error finding parent ${item.parentId} for entry ${item.id}');
            continue;
          }
          final parentPostNode = _postNodes[parentParentPostId]!;
          postNodesToReturn.add(parentPostNode);
          _parentPostIds[item.id] = parentPostNode.id;
          if (parentPostNode.getChildById(item.id) == null) {
            final newNode = _Node(item.id);
            final injectionNode = parentPostNode.id == item.parentId
                ? parentPostNode
                : parentPostNode.getChildById(item.parentId);
            if (injectionNode == null) {
              continue;
            } else {
              injectionNode.addChild(newNode);
            }
          }
          allSeenItems.remove(item);
        }
      }
      if (allSeenItems.isNotEmpty && allSeenItems.length == lastCount) {
        _logger.severe(
            'Had ${allSeenItems.length} items left over after all iterations');
        break;
      }
      lastCount = allSeenItems.length;
    }

    final updatedPosts = postNodesToReturn
        .map((node) => _nodeToTreeItem(node, currentId))
        .toList();

    _logger.finest(
        'Completed processing new items ${client == null ? 'sub level' : 'top level'}');
    return updatedPosts;
  }

  FutureResult<EntryTreeItem, ExecError> refreshStatusChain(String id) async {
    _logger.finest('Refreshing post: $id');
    final client = StatusesClient(profile);
    final result = await client
        .getPostOrComment(id, fullContext: false)
        .andThenAsync((rootItems) async => await client
            .getPostOrComment(id, fullContext: true)
            .andThenSuccessAsync(
                (contextItems) async => [...rootItems, ...contextItems]))
        .withResult((items) async {
      _cleanupEntriesForId(id);
      await processNewItems(items, client.profile.username, null);
    });

    return result.mapValue((_) {
      _logger.finest('$id post updated');
      return _nodeToTreeItem(_getPostRootNode(id)!, client.profile.userId);
    }).mapError(
      (error) {
        _logger.finest('$id error updating: $error');
        return ExecError(
          type: ErrorType.localError,
          message: error.toString(),
        );
      },
    );
  }

  FutureResult<EntryTreeItem, ExecError> resharePost(String id) async {
    _logger.finest('Resharing post: $id');
    final resharedViaService = getIt<ActiveProfileSelector<ReshareViaService>>()
        .getForProfile(profile)
        .fold(
            onSuccess: (s) => s,
            onError: (error) {
              _logger.severe('Error getting reshared via service: $error');
              return null;
            });
    final client = StatusesClient(profile);
    final idForCall = id;
    final result =
        await client.resharePost(idForCall).andThenSuccessAsync((item) async {
      resharedViaService?.upsertResharedVia(postId: id, resharerId: profile.id);
      await processNewItems([item], client.profile.username, null);
    });

    return result
        .mapValue((_) {
          _logger.finest('$id post updated after reshare');
          return _nodeToTreeItem(_postNodes[id]!, client.profile.userId);
        })
        .withResult((_) => notifyListeners())
        .mapError(
          (error) {
            _logger.finest('$id error updating: $error');
            return ExecError(
              type: ErrorType.localError,
              message: error.toString(),
            );
          },
        );
  }

  FutureResult<EntryTreeItem, ExecError> unResharePost(String id) async {
    _logger.finest('Unresharing post: $id');
    final resharedViaService = getIt<ActiveProfileSelector<ReshareViaService>>()
        .getForProfile(profile)
        .fold(
            onSuccess: (s) => s,
            onError: (error) {
              _logger.severe('Error getting reshared via service: $error');
              return null;
            });
    final client = StatusesClient(profile);
    final idForCall = id;
    final result =
        await client.unResharePost(idForCall).andThenSuccessAsync((item) async {
      resharedViaService?.upsertRemovedSharer(
          postId: id, resharerId: profile.id);
      await processNewItems([item], client.profile.username, null);
    });

    if (result.isFailure) {
      return Result.error(result.error);
    }

    return result
        .mapValue((_) {
          _logger.finest('$id post updated after reshare');
          return _nodeToTreeItem(_postNodes[id]!, client.profile.userId);
        })
        .withResult((_) => notifyListeners())
        .mapError(
          (error) {
            _logger.finest('$id error updating: $error');
            return ExecError(
              type: ErrorType.localError,
              message: error.toString(),
            );
          },
        );
  }

  FutureResult<EntryTreeItem, ExecError> toggleFavorited(
      String id, bool newStatus) async {
    final interactionClient = InteractionsClient(profile);
    final postsClient = StatusesClient(profile);
    final idForCall = id;
    final result =
        await interactionClient.changeFavoriteStatus(idForCall, newStatus);
    if (result.isFailure) {
      return result.errorCast();
    }

    final updateResult =
        await postsClient.getPostOrComment(id, fullContext: false);
    if (updateResult.isFailure) {
      return updateResult.errorCast();
    }
    final update = updateResult.value.first;
    _entries[update.id] = update;
    final node = update.parentId.isEmpty
        ? _postNodes[update.id]!
        : _postNodes[_parentPostIds[update.id]]!;

    notifyListeners();
    return Result.ok(_nodeToTreeItem(node, interactionClient.profile.userId));
  }

  EntryTreeItem _nodeToTreeItem(_Node node, String currentId) {
    final childenEntries = <String, EntryTreeItem>{};
    for (final c in node.children) {
      childenEntries[c.id] = _nodeToTreeItem(c, currentId);
    }
    final entry = _entries[node.id]!;
    final isMine = entry.authorId == currentId;
    return EntryTreeItem(
      _entries[node.id]!,
      isMine: isMine,
      initialChildren: childenEntries,
    );
  }

  void _cleanupEntriesForId(String id) {
    if (_parentPostIds.containsKey(id)) {
      final parentPostId = _parentPostIds.remove(id);
      final parentPostNode = _postNodes[parentPostId];
      parentPostNode?.removeChildById(id);
    }

    if (_entries.containsKey(id)) {
      _entries.remove(id);
    }

    if (_postNodes.containsKey(id)) {
      _postNodes.remove(id);
    }
  }
}

class _Node {
  final String id;
  final _children = <String, _Node>{};

  List<_Node> get children => _children.values.toList();

  _Node(this.id, {Map<String, _Node>? initialChildren}) {
    if (initialChildren != null) {
      _children.addAll(initialChildren);
    }
  }

  void addChild(_Node node) {
    _children[node.id] = node;
  }

  void removeChildById(String id) {
    if (_children.containsKey(id)) {
      _children.remove(id);
    }

    for (final c in _children.values) {
      c.removeChildById(id);
    }
  }

  _Node? getChildById(String id) {
    if (_children.containsKey(id)) {
      return _children[id]!;
    }

    for (final c in _children.values) {
      final result = c.getChildById(id);
      if (result != null) {
        return result;
      }
    }

    return null;
  }

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is _Node && runtimeType == other.runtimeType && id == other.id;

  @override
  int get hashCode => id.hashCode;
}
