// Copyright 2020-2024 CesiumGS, Inc. and Contributors

#include "CesiumCreditSystem.h"
#include "CesiumCommon.h"
#include "CesiumCreditSystemBPLoader.h"
#include "CesiumRuntime.h"
#include "CesiumUtility/CreditSystem.h"
#include "Engine/World.h"
#include "EngineUtils.h"
#include "ScreenCreditsWidget.h"
#include <string>
#include <tidybuffio.h>
#include <vector>

#if WITH_EDITOR
#include "Editor.h"
#include "EditorSupportDelegates.h"
#include "GameDelegates.h"
#include "IAssetViewport.h"
#include "LevelEditor.h"
#include "Modules/ModuleManager.h"
#endif

/*static*/ UObject* ACesiumCreditSystem::CesiumCreditSystemBP = nullptr;
namespace {

/**
 * @brief Tries to find the default credit system in the given level.
 *
 * This will search all actors of the given level for a `ACesiumCreditSystem`
 * whose name starts with `"CesiumCreditSystemDefault"` that is *valid*
 * (i.e. not pending kill).
 *
 * @param Level The level
 * @return The default credit system, or `nullptr` if there is none.
 */
ACesiumCreditSystem* findValidDefaultCreditSystem(ULevel* Level) {
  if (!IsValid(Level)) {
    UE_LOG(
        LogCesium,
        Warning,
        TEXT("No valid level for findValidDefaultCreditSystem"));
    return nullptr;
  }
#if ENGINE_VERSION_5_4_OR_HIGHER
  TArray<TObjectPtr<AActor>>& Actors = Level->Actors;
  using LevelActorPointer = TObjectPtr<AActor>*;
#else
  TArray<AActor*>& Actors = Level->Actors;
  using LevelActorPointer = AActor**;
#endif
  LevelActorPointer DefaultCreditSystemPtr =
      Actors.FindByPredicate([](AActor* const& InItem) {
        if (!IsValid(InItem)) {
          return false;
        }
        if (!InItem->IsA(ACesiumCreditSystem::StaticClass())) {
          return false;
        }
        if (!InItem->GetName().StartsWith("CesiumCreditSystemDefault")) {
          return false;
        }
        return true;
      });
  if (!DefaultCreditSystemPtr) {
    return nullptr;
  }
  AActor* DefaultCreditSystem = *DefaultCreditSystemPtr;
  return Cast<ACesiumCreditSystem>(DefaultCreditSystem);
}

bool checkIfInSubLevel(ACesiumCreditSystem* pCreditSystem) {
  if (pCreditSystem->GetLevel() != pCreditSystem->GetWorld()->PersistentLevel) {
    UE_LOG(
        LogCesium,
        Warning,
        TEXT(
            "CesiumCreditSystem should only exist in the Persistent Level. Adding it to a sub-level may cause credits to be lost."));
    return true;
  } else {
    return false;
  }
}

} // namespace

FName ACesiumCreditSystem::DEFAULT_CREDITSYSTEM_TAG =
    FName("DEFAULT_CREDITSYSTEM");

/*static*/ ACesiumCreditSystem*
ACesiumCreditSystem::GetDefaultCreditSystem(const UObject* WorldContextObject) {
  // Blueprint loading can only happen in a constructor, so we instantiate a
  // loader object that retrieves the blueprint class in its constructor. We can
  // destroy the loader immediately once it's done since it will have already
  // set CesiumCreditSystemBP.
  if (!CesiumCreditSystemBP) {
    UCesiumCreditSystemBPLoader* bpLoader =
        NewObject<UCesiumCreditSystemBPLoader>();
    CesiumCreditSystemBP = bpLoader->CesiumCreditSystemBP.LoadSynchronous();
    bpLoader->ConditionalBeginDestroy();
  }

  UWorld* world = WorldContextObject->GetWorld();
  // This method can be called by actors even when opening the content browser.
  if (!IsValid(world)) {
    return nullptr;
  }
  UE_LOG(
      LogCesium,
      Verbose,
      TEXT("World name for GetDefaultCreditSystem: %s"),
      *world->GetFullName());

  // Note: The actor iterator will be created with the
  // "EActorIteratorFlags::SkipPendingKill" flag,
  // meaning that we don't have to handle objects
  // that have been deleted. (This is the default,
  // but made explicit here)
  ACesiumCreditSystem* pCreditSystem = nullptr;
  EActorIteratorFlags flags = EActorIteratorFlags::OnlyActiveLevels |
                              EActorIteratorFlags::SkipPendingKill;
  for (TActorIterator<AActor> actorIterator(
           world,
           ACesiumCreditSystem::StaticClass(),
           flags);
       actorIterator;
       ++actorIterator) {
    AActor* actor = *actorIterator;
    if (actor->GetLevel() == world->PersistentLevel &&
        actor->ActorHasTag(DEFAULT_CREDITSYSTEM_TAG)) {
      pCreditSystem = Cast<ACesiumCreditSystem>(actor);
      break;
    }
  }
  if (!pCreditSystem) {
    // Legacy method of finding Georeference, for backwards compatibility with
    // existing projects
    ACesiumCreditSystem* pCreditSystemCandidate =
        findValidDefaultCreditSystem(world->PersistentLevel);

    // Test if PendingKill
    if (IsValid(pCreditSystemCandidate)) {
      pCreditSystem = pCreditSystemCandidate;
    }
  }
  if (!pCreditSystem) {
    UE_LOG(
        LogCesium,
        Verbose,
        TEXT("Creating default Credit System for actor %s"),
        *WorldContextObject->GetName());
    // Spawn georeference in the persistent level
    FActorSpawnParameters spawnParameters;
    spawnParameters.SpawnCollisionHandlingOverride =
        ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
    spawnParameters.OverrideLevel = world->PersistentLevel;
    pCreditSystem = world->SpawnActor<ACesiumCreditSystem>(
        Cast<UClass>(CesiumCreditSystemBP),
        spawnParameters);
    // Null check so the editor doesn't crash when it makes arbitrary calls to
    // this function without a valid world context object.
    if (pCreditSystem) {
      pCreditSystem->Tags.Add(DEFAULT_CREDITSYSTEM_TAG);
    }
  } else {
    UE_LOG(
        LogCesium,
        Verbose,
        TEXT("Using existing CreditSystem %s for actor %s"),
        *pCreditSystem->GetName(),
        *WorldContextObject->GetName());
  }
  return pCreditSystem;
}

ACesiumCreditSystem::ACesiumCreditSystem()
    : AActor(),
      _pCreditSystem(std::make_shared<CesiumUtility::CreditSystem>()),
      _lastCreditsCount(0) {
  PrimaryActorTick.bCanEverTick = true;
#if WITH_EDITOR
  this->SetIsSpatiallyLoaded(false);
#endif
}

void ACesiumCreditSystem::BeginPlay() {
  Super::BeginPlay();

  if (checkIfInSubLevel(this))
    return;

  this->updateCreditsViewport(true);
}

void ACesiumCreditSystem::EndPlay(const EEndPlayReason::Type EndPlayReason) {
  this->removeCreditsFromViewports();
  Super::EndPlay(EndPlayReason);
}

static const FName LevelEditorName("LevelEditor");

void ACesiumCreditSystem::OnConstruction(const FTransform& Transform) {
  Super::OnConstruction(Transform);

  if (checkIfInSubLevel(this))
    return;

  this->updateCreditsViewport(false);

#if WITH_EDITOR
  FLevelEditorModule* pLevelEditorModule =
      FModuleManager::GetModulePtr<FLevelEditorModule>(LevelEditorName);

  if (pLevelEditorModule && !GetWorld()->IsGameWorld()) {
    pLevelEditorModule->OnRedrawLevelEditingViewports().RemoveAll(this);
    pLevelEditorModule->OnRedrawLevelEditingViewports().AddUObject(
        this,
        &ACesiumCreditSystem::OnRedrawLevelEditingViewports);

    FEditorSupportDelegates::CleanseEditor.RemoveAll(this);
    FEditorSupportDelegates::CleanseEditor.AddUObject(
        this,
        &ACesiumCreditSystem::OnCleanseEditor);

    FEditorDelegates::PreBeginPIE.RemoveAll(this);
    FEditorDelegates::PreBeginPIE.AddUObject(
        this,
        &ACesiumCreditSystem::OnPreBeginPIE);

    FGameDelegates::Get().GetEndPlayMapDelegate().RemoveAll(this);
    FGameDelegates::Get().GetEndPlayMapDelegate().AddUObject(
        this,
        &ACesiumCreditSystem::OnEndPIE);
  }
#endif
}

void ACesiumCreditSystem::BeginDestroy() {
#if WITH_EDITOR
  FLevelEditorModule* pLevelEditorModule =
      FModuleManager::GetModulePtr<FLevelEditorModule>(LevelEditorName);
  if (pLevelEditorModule) {
    pLevelEditorModule->OnRedrawLevelEditingViewports().RemoveAll(this);
  }

  FEditorSupportDelegates::CleanseEditor.RemoveAll(this);
  FEditorDelegates::PreBeginPIE.RemoveAll(this);
  FEditorDelegates::EndPIE.RemoveAll(this);
#endif

  Super::BeginDestroy();
}

void ACesiumCreditSystem::updateCreditsViewport(bool recreateWidget) {
  if (IsRunningDedicatedServer())
    return;
  if (!IsValid(GetWorld()))
    return;

  if (!IsValid(CreditsWidget) || recreateWidget) {
    CreditsWidget =
        CreateWidget<UScreenCreditsWidget>(GetWorld(), CreditsWidgetClass);
  }

#if WITH_EDITOR
  FLevelEditorModule* pLevelEditorModule =
      FModuleManager::GetModulePtr<FLevelEditorModule>(LevelEditorName);

  if (pLevelEditorModule && !GetWorld()->IsGameWorld()) {
    // Add credits to the active editor viewport
    TSharedPtr<IAssetViewport> pActiveViewport =
        pLevelEditorModule->GetFirstActiveViewport();
    if (pActiveViewport.IsValid() &&
        this->_pLastEditorViewport != pActiveViewport) {
      this->removeCreditsFromViewports();

      if (!pActiveViewport->HasPlayInEditorViewport()) {
        auto pSlateWidget = CreditsWidget->TakeWidget();
        pActiveViewport->AddOverlayWidget(pSlateWidget);
        this->_pLastEditorViewport = pActiveViewport;
      }
    }
    return;
  }

  this->removeCreditsFromViewports();
#endif

  // Add credits to a game viewport
  CreditsWidget->AddToViewport();
}

void ACesiumCreditSystem::removeCreditsFromViewports() {
#if WITH_EDITOR
  if (this->_pLastEditorViewport.IsValid()) {
    auto pPinned = this->_pLastEditorViewport.Pin();
    pPinned->RemoveOverlayWidget(CreditsWidget->TakeWidget());
    this->_pLastEditorViewport = nullptr;
  }
#endif

  if (IsValid(CreditsWidget)) {
    CreditsWidget->RemoveFromParent();
  }
}

#if WITH_EDITOR
void ACesiumCreditSystem::OnRedrawLevelEditingViewports(bool) {
  this->updateCreditsViewport(false);
}

void ACesiumCreditSystem::OnPreBeginPIE(bool bIsSimulating) {
  // When we start play-in-editor, remove the editor viewport credits.
  // The game will often reuse the same viewport, and we don't want to show
  // two sets of credits.
  this->removeCreditsFromViewports();
}

void ACesiumCreditSystem::OnEndPIE() { this->updateCreditsViewport(false); }

void ACesiumCreditSystem::OnCleanseEditor() {
  // This is called late in the process of unloading a level.
  this->removeCreditsFromViewports();
}
#endif

bool ACesiumCreditSystem::ShouldTickIfViewportsOnly() const { return true; }

void ACesiumCreditSystem::Tick(float DeltaTime) {
  Super::Tick(DeltaTime);

  if (!_pCreditSystem || !IsValid(CreditsWidget)) {
    return;
  }

  const std::vector<CesiumUtility::Credit>& creditsToShowThisFrame =
      _pCreditSystem->getCreditsToShowThisFrame();

  // if the credit list has changed, we want to reformat the credits
  CreditsUpdated =
      creditsToShowThisFrame.size() != _lastCreditsCount ||
      _pCreditSystem->getCreditsToNoLongerShowThisFrame().size() > 0;

  if (CreditsUpdated) {
    FString OnScreenCredits;
    FString Credits;

    _lastCreditsCount = creditsToShowThisFrame.size();

    bool firstCreditOnScreen = true;
    for (int i = 0; i < creditsToShowThisFrame.size(); i++) {
      const CesiumUtility::Credit& credit = creditsToShowThisFrame[i];

      FString CreditRtf;
      const std::string& html = _pCreditSystem->getHtml(credit);

      auto htmlFind = _htmlToRtf.find(html);
      if (htmlFind != _htmlToRtf.end()) {
        CreditRtf = htmlFind->second;
      } else {
        CreditRtf = ConvertHtmlToRtf(html);
        _htmlToRtf.insert({html, CreditRtf});
      }

      if (_pCreditSystem->shouldBeShownOnScreen(credit)) {
        if (firstCreditOnScreen) {
          firstCreditOnScreen = false;
        } else {
          OnScreenCredits += TEXT(" \u2022 ");
        }

        OnScreenCredits += CreditRtf;
      } else {
        if (i != 0) {
          Credits += "\n";
        }

        Credits += CreditRtf;
      }
    }

    if (!Credits.IsEmpty()) {
      OnScreenCredits += "<credits url=\"popup\" text=\" Data attribution\"/>";
    }

    CreditsWidget->SetCredits(Credits, OnScreenCredits);
  }
  _pCreditSystem->startNextFrame();
}

namespace {
void convertHtmlToRtf(
    std::string& output,
    std::string& parentUrl,
    TidyDoc tdoc,
    TidyNode tnod,
    UScreenCreditsWidget* CreditsWidget) {
  TidyNode child;
  TidyBuffer buf;
  tidyBufInit(&buf);
  for (child = tidyGetChild(tnod); child; child = tidyGetNext(child)) {
    if (tidyNodeIsText(child)) {
      tidyNodeGetText(tdoc, child, &buf);
      if (buf.bp) {
        std::string text = reinterpret_cast<const char*>(buf.bp);
        tidyBufClear(&buf);
        // could not find correct option in tidy html to not add new lines
        if (text.size() != 0 && text[text.size() - 1] == '\n') {
          text.pop_back();
        }
        if (!parentUrl.empty()) {
          output +=
              "<credits url=\"" + parentUrl + "\"" + " text=\"" + text + "\"/>";
        } else {
          output += text;
        }
      }
    } else if (tidyNodeGetId(child) == TidyTagId::TidyTag_IMG) {
      auto srcAttr = tidyAttrGetById(child, TidyAttrId::TidyAttr_SRC);
      if (srcAttr) {
        auto srcValue = tidyAttrValue(srcAttr);
        if (srcValue) {
          output += "<credits id=\"" +
                    CreditsWidget->LoadImage(
                        std::string(reinterpret_cast<const char*>(srcValue))) +
                    "\"";
          if (!parentUrl.empty()) {
            output += " url=\"" + parentUrl + "\"";
          }
          output += "/>";
        }
      }
    }
    auto hrefAttr = tidyAttrGetById(child, TidyAttrId::TidyAttr_HREF);
    if (hrefAttr) {
      auto hrefValue = tidyAttrValue(hrefAttr);
      parentUrl = std::string(reinterpret_cast<const char*>(hrefValue));
    }
    convertHtmlToRtf(output, parentUrl, tdoc, child, CreditsWidget);
  }
  tidyBufFree(&buf);
}
} // namespace

FString ACesiumCreditSystem::ConvertHtmlToRtf(std::string html) {
  TidyDoc tdoc;
  TidyBuffer tidy_errbuf = {0};
  int err;

  tdoc = tidyCreate();
  tidyOptSetBool(tdoc, TidyForceOutput, yes);
  tidyOptSetInt(tdoc, TidyWrapLen, 0);
  tidyOptSetInt(tdoc, TidyNewline, TidyLF);

  tidySetErrorBuffer(tdoc, &tidy_errbuf);

  html = "<!DOCTYPE html><html><body>" + html + "</body></html>";

  std::string output, url;
  err = tidyParseString(tdoc, html.c_str());
  if (err < 2) {
    convertHtmlToRtf(output, url, tdoc, tidyGetRoot(tdoc), CreditsWidget);
  }
  tidyBufFree(&tidy_errbuf);
  tidyRelease(tdoc);
  return UTF8_TO_TCHAR(output.c_str());
}