/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */
import {
  afterNextRender,
  ɵpromiseWithResolvers as promiseWithResolvers,
  DestroyRef,
  EnvironmentInjector,
  inject,
  Injectable,
} from '@angular/core';

import {
  PlatformLocation,
  PlatformNavigation,
  ɵPRECOMMIT_HANDLER_SUPPORTED as PRECOMMIT_HANDLER_SUPPORTED,
} from '@angular/common';
import {StateManager} from './state_manager';
import {
  NavigationExtras,
  RestoredState,
  Navigation as RouterNavigation,
} from '../navigation_transition';
import {
  BeforeActivateRoutes,
  BeforeRoutesRecognized,
  isRedirectingEvent,
  NavigationCancel,
  NavigationCancellationCode,
  NavigationEnd,
  NavigationError,
  NavigationSkipped,
  NavigationStart,
  NavigationTrigger,
  PrivateRouterEvents,
} from '../events';
import {Subject, SubscriptionLike} from 'rxjs';
import {UrlTree} from '../url_tree';
import {ROUTER_SCROLLER} from '../router_scroller';

type NavigationInfo = {ɵrouterInfo: {intercept: boolean}};

@Injectable({providedIn: 'root'})
/**
 * A `StateManager` that uses the browser's Navigation API to get the state of a `popstate`
 * event.
 *
 * This class is currently an extension of `HistoryStateManager` and is used when the
 * Navigation API is available. It overrides the behavior of listening to `popstate` events
 * to retrieve the state from `navigation.currentEntry` instead of `history.state` since
 * history and navigation states are separate.
 *
 * This implementation is not complete - it does not integrate at all with navigation API other than
 * providing the right state on popstate. It needs to manage the whole lifecycle of the navigation
 * by intercepting the navigation event.
 */
export class NavigationStateManager extends StateManager {
  private readonly injector = inject(EnvironmentInjector);
  private readonly navigation = inject(PlatformNavigation);
  private readonly inMemoryScrollingEnabled = inject(ROUTER_SCROLLER, {optional: true}) !== null;
  /** The base origin of the application, extracted from PlatformLocation. */
  private readonly base = new URL(inject(PlatformLocation).href).origin;
  /** The root URL of the Angular application, considering the base href. */
  private readonly appRootURL = new URL(this.location.prepareExternalUrl?.('/') ?? '/', this.base)
    .href;
  private readonly precommitHandlerSupported = inject(PRECOMMIT_HANDLER_SUPPORTED);
  /**
   * The `NavigationHistoryEntry` from the Navigation API that corresponds to the last successfully
   * activated router state. This is crucial for restoring the browser state if an ongoing navigation
   * is canceled or fails, allowing a precise rollback to a known good entry.
   * It's updated on `navigatesuccess`.
   */
  private activeHistoryEntry: NavigationHistoryEntry = this.navigation.currentEntry!;

  /**
   * Holds state related to the currently processing navigation that was intercepted from a
   * `navigate` event. This includes the router's internal `Navigation` object.
   */
  private currentNavigation: {
    removeAbortListener?: () => void;
    /** The Angular Router's internal representation of the ongoing navigation. */
    routerTransition?: RouterNavigation;
    /** Function to reject the intercepted navigation event. */
    rejectNavigateEvent?: (reason?: any) => void;
    /** Function to resolve the intercepted navigation event. */
    resolveHandler?: (v: void) => void;
    navigationEvent?: NavigateEvent;
    commitUrl?: () => Promise<void>;
  } = {};

  /**
   * Subject used to notify listeners (typically the `Router`) of URL/state changes
   * that were initiated outside the Angular Router but detected via the Navigation API's
   * `navigate` event (e.g., user clicking browser back/forward, or manual URL changes if
   * interceptable by the Navigation API).
   */
  private nonRouterCurrentEntryChangeSubject = new Subject<{
    path: string;
    state: RestoredState | null | undefined;
  }>();

  nonRouterEntryChangeListener?: SubscriptionLike;
  private get registered() {
    return (
      this.nonRouterEntryChangeListener !== undefined && !this.nonRouterEntryChangeListener.closed
    );
  }

  constructor() {
    super();

    // Listen to the 'navigate' event from the Navigation API.
    // This is the primary entry point for intercepting and handling navigations.
    const navigateListener = (event: NavigateEvent) => {
      this.handleNavigate(event);
    };
    this.navigation.addEventListener('navigate', navigateListener);
    inject(DestroyRef).onDestroy(() =>
      this.navigation.removeEventListener('navigate', navigateListener),
    );
  }

  override registerNonRouterCurrentEntryChangeListener(
    listener: (
      url: string,
      state: RestoredState | null | undefined,
      trigger: NavigationTrigger,
      extras: NavigationExtras,
    ) => void,
  ): SubscriptionLike {
    this.activeHistoryEntry = this.navigation.currentEntry!;
    this.nonRouterEntryChangeListener = this.nonRouterCurrentEntryChangeSubject.subscribe(
      ({path, state}) => {
        listener(
          path,
          state,
          'popstate',
          !this.precommitHandlerSupported ? {replaceUrl: true} : {},
        );
      },
    );
    return this.nonRouterEntryChangeListener;
  }

  /**
   * Handles router events emitted by the `NavigationTransitions` service.
   * This method orchestrates the interaction with the Navigation API based on the
   * current stage of the router's internal navigation pipeline.
   *
   * @param e The router event (e.g., `NavigationStart`, `NavigationEnd`).
   * @param transition The Angular Router's internal navigation object.
   */
  override async handleRouterEvent(
    e: Event | PrivateRouterEvents,
    transition: RouterNavigation,
  ): Promise<void> {
    this.currentNavigation = {...this.currentNavigation, routerTransition: transition};
    if (e instanceof NavigationStart) {
      this.updateStateMemento();
      // If we have precommit handler support, we can create a navigation
      // immediately and redirect it later.
      if (this.precommitHandlerSupported) {
        this.maybeCreateNavigationForTransition(transition);
      }
    } else if (e instanceof NavigationSkipped) {
      this.finishNavigation();
      this.commitTransition(transition);
    } else if (e instanceof BeforeRoutesRecognized) {
      transition.routesRecognizeHandler.deferredHandle = new Promise<void>(async (resolve) => {
        if (this.urlUpdateStrategy === 'eager') {
          try {
            this.maybeCreateNavigationForTransition(transition);
            await this.currentNavigation.commitUrl?.();
          } catch {
            // If commit fails (e.g., precommitHandler rejects), abort.
            // The AbortSignal will notify
            return;
          }
        }
        resolve();
      });
    } else if (e instanceof BeforeActivateRoutes) {
      transition.beforeActivateHandler.deferredHandle = new Promise<void>(async (resolve) => {
        // If URL update strategy is 'deferred', commit the URL now (before activation).
        if (this.urlUpdateStrategy === 'deferred') {
          try {
            this.maybeCreateNavigationForTransition(transition);
            await this.currentNavigation.commitUrl?.();
          } catch {
            return;
          }
        }
        // Commit the internal router state.
        this.commitTransition(transition);
        resolve();
      });
    } else if (e instanceof NavigationCancel || e instanceof NavigationError) {
      // If redirecting and the URL hasn't been committed yet (via precommmitHandler),
      // the redirect will be handled by `commitUrl` using `controller.redirect` and
      // we should retain the current NavigateEvent.
      // Otherwise, a full cancellation and rollback is needed.
      const redirectingBeforeUrlCommit =
        e instanceof NavigationCancel &&
        e.code === NavigationCancellationCode.Redirect &&
        !!this.currentNavigation.commitUrl;
      if (redirectingBeforeUrlCommit) {
        return;
      }
      void this.cancel(transition, e);
    } else if (e instanceof NavigationEnd) {
      const {resolveHandler, removeAbortListener} = this.currentNavigation;
      this.currentNavigation = {};
      // We no longer care about aborts for this navigation once it's successfully ended.
      // Since we're delaying the resolution of the handler until after next render, it's
      // technically possible for it to still get aborted in that window, so we remove the listener here.
      removeAbortListener?.();
      // Update `activeHistoryEntry` to the new current entry from Navigation API.
      this.activeHistoryEntry = this.navigation.currentEntry!;
      // TODO(atscott): Consider initiating scroll here since it will be attempted periodically.
      // We have to wait for render to resolve because focus reset is only done once in the spec.
      // Render is not synchronous with NavigationEnd today. The Router's navigation promise resolve
      // is what _causes_ the render to happen with ZoneJS...
      // Resolve handler after next render to defer scroll and focus reset.
      afterNextRender({read: () => resolveHandler?.()}, {injector: this.injector});
    }
  }

  private maybeCreateNavigationForTransition(transition: RouterNavigation) {
    const {navigationEvent, commitUrl} = this.currentNavigation;
    if (
      // Presence of commitUrl function indicates the navigateEvent supports redirect
      commitUrl ||
      // If we are currently handling a traversal navigation, we do not need a new navigation for it
      // because we are strictly restoring a previous state. If we are instead handling a navigation
      // initiated outside the router, we do need to replace it with a router-triggered navigation
      // to add the router-specific state.
      (navigationEvent &&
        navigationEvent.navigationType === 'traverse' &&
        this.eventAndRouterDestinationsMatch(navigationEvent, transition))
    ) {
      return;
    }
    // Before we create a navigation for the Router transition, we have to remove any abort listeners
    // from the previous navigation event. Creating the new navigation will cause the signal
    // to be aborted, and we don't want that to cause our router transition to be aborted.
    this.currentNavigation.removeAbortListener?.();
    const path = this.createBrowserPath(transition);
    this.navigate(path, transition);
  }

  /**
   * Initiates a navigation using the browser's Navigation API (`navigation.navigate`).
   * This is called when the Angular Router starts an imperative navigation.
   *
   * @param internalPath The internal path generated by the router.
   * @param transition The Angular Router's navigation object.
   */
  private navigate(internalPath: string, transition: RouterNavigation) {
    // Determine the actual browser path, considering skipLocationChange.
    const path = transition.extras.skipLocationChange
      ? this.navigation.currentEntry!.url! // If skipping, use the current URL.
      : this.location.prepareExternalUrl(internalPath); // Otherwise, prepare the external URL.

    // Prepare the state to be stored in the NavigationHistoryEntry.
    const state = {
      ...transition.extras.state,
      ...this.generateNgRouterState(transition),
    };

    const info: NavigationInfo = {ɵrouterInfo: {intercept: true}};
    // https://issues.chromium.org/issues/460137775 - Bug in all browsers where URL might actually not be updated
    // by the time we get here. replaceUrl was set to true in the Router when navigating to sync with the browser
    // because it assumes the URL is already committed. In this scenario, we need to go back to 'push' behavior
    // because it was not yet been committed and we should not replace the current entry.
    if (!this.navigation.transition && this.currentNavigation.navigationEvent) {
      transition.extras.replaceUrl = false;
    }

    // Determine if this should be a 'push' or 'replace' history operation.
    const history =
      this.location.isCurrentPathEqualTo(path) ||
      transition.extras.replaceUrl ||
      transition.extras.skipLocationChange
        ? 'replace'
        : 'push';

    // Call the Navigation API and prevent unhandled promise rejections of the
    // returned promises from `navigation.navigate`.
    handleResultRejections(
      this.navigation.navigate(path, {
        state,
        history,
        info,
      }),
    );
  }

  /**
   * Finalizes the current navigation by committing the URL (if not already done)
   * and resolving the post-commit handler promise. Clears the `currentNavigation` state.
   */
  private finishNavigation() {
    this.currentNavigation.commitUrl?.();
    this.currentNavigation?.resolveHandler?.();
    this.currentNavigation = {};
  }

  /**
   * Performs the necessary rollback action to restore the browser URL to the
   * state before the transition.
   */
  private async cancel(transition: RouterNavigation, cause: NavigationCancel | NavigationError) {
    this.currentNavigation.rejectNavigateEvent?.();
    const clearedState = {}; // Marker to detect if a new navigation started during async ops.
    this.currentNavigation = clearedState;
    // Do not reset state if we're redirecting or navigation is superseded by a new one.
    if (isRedirectingEvent(cause)) {
      return;
    }
    // Determine if the rollback should be a traversal to a specific previous entry
    // or a replacement of the current URL.
    const isTraversalReset =
      this.canceledNavigationResolution === 'computed' &&
      this.navigation.currentEntry!.key !== this.activeHistoryEntry.key;
    this.resetInternalState(transition.finalUrl, isTraversalReset);

    // If the current browser entry ID is already the same as our target active entry,
    // no browser history manipulation is needed.
    if (this.navigation.currentEntry!.id === this.activeHistoryEntry.id) {
      return;
    }

    // If the cancellation was not due to a guard or resolver (e.g., superseded by another
    // navigation, or aborted by user), there's a race condition. Another navigation might
    // have already started. A delay is used to see if `currentNavigation` changes,
    // indicating a new navigation has taken over.
    // We have no way of knowing if a navigation was aborted by another incoming navigation
    // https://github.com/WICG/navigation-api/issues/288
    if (cause instanceof NavigationCancel && cause.code === NavigationCancellationCode.Aborted) {
      await Promise.resolve();
      if (this.currentNavigation !== clearedState) {
        // A new navigation has started, so don't attempt to roll back this one.
        return;
      }
    }

    if (isTraversalReset) {
      // Traverse back to the specific `NavigationHistoryEntry` that was active before.
      handleResultRejections(
        this.navigation.traverseTo(this.activeHistoryEntry.key, {
          info: {ɵrouterInfo: {intercept: false}} satisfies NavigationInfo,
        }),
      );
    } else {
      // Replace the current history entry with the state of the last known good URL/state.
      const internalPath = this.urlSerializer.serialize(this.getCurrentUrlTree());
      const pathOrUrl = this.location.prepareExternalUrl(internalPath);
      handleResultRejections(
        this.navigation.navigate(pathOrUrl, {
          state: this.activeHistoryEntry.getState(),
          history: 'replace',
          info: {ɵrouterInfo: {intercept: false}} satisfies NavigationInfo,
        }),
      );
    }
  }

  private resetInternalState(finalUrl: UrlTree | undefined, traversalReset: boolean): void {
    this.routerState = this.stateMemento.routerState;
    this.currentUrlTree = this.stateMemento.currentUrlTree;
    this.rawUrlTree = traversalReset
      ? this.stateMemento.rawUrlTree
      : this.urlHandlingStrategy.merge(this.currentUrlTree, finalUrl ?? this.rawUrlTree);
  }

  /**
   * Handles the `navigate` event from the browser's Navigation API.
   * This is the core interception point.
   *
   * @param event The `NavigateEvent` from the Navigation API.
   */
  private handleNavigate(event: NavigateEvent) {
    // If the event cannot be intercepted (e.g., cross-origin, or some browser-internal
    // navigations), let the browser handle it.
    // We also do not convert reload navigation events to SPA navigations. Intercepting
    // would prevent the generally expected hard refresh. If an application wants special
    // handling for reloads, they can implement it in their own `navigate` event listener.
    if (!event.canIntercept || event.navigationType === 'reload') {
      return;
    }

    const routerInfo = (event?.info as NavigationInfo | undefined)?.ɵrouterInfo;
    if (routerInfo && !routerInfo.intercept) {
      return;
    }
    const isTriggeredByRouterTransition = !!routerInfo;
    if (!isTriggeredByRouterTransition) {
      // If there's an ongoing navigation in the Angular Router, abort it. This new navigation
      // supersedes it. If the navigation was triggered by the Router, it may be the navigation
      // happening from _inside_ the navigation transition, or a separate Router.navigate call
      // that would have already handled cleanup of the previous navigation.
      this.currentNavigation.routerTransition?.abort();

      if (!this.registered) {
        // If the router isn't set up to listen for these yet. Do not convert it to a router navigation.
        this.finishNavigation();
        return;
      }
    }

    this.currentNavigation = {...this.currentNavigation};
    this.currentNavigation.navigationEvent = event;
    // Setup an abort handler. If the `NavigateEvent` is aborted (e.g., user clicks stop,
    // or another navigation supersedes this one), we need to abort the Angular Router's
    // internal navigation transition as well.
    const abortHandler = () => {
      this.currentNavigation.routerTransition?.abort();
    };
    event.signal.addEventListener('abort', abortHandler);
    this.currentNavigation.removeAbortListener = () =>
      event.signal.removeEventListener('abort', abortHandler);

    let scroll = this.inMemoryScrollingEnabled
      ? 'manual'
      : (this.currentNavigation.routerTransition?.extras.scroll ?? 'after-transition');
    const interceptOptions: NavigationInterceptOptions = {
      scroll,
    };

    const {
      promise: handlerPromise,
      resolve: resolveHandler,
      reject: rejectHandler,
    } = promiseWithResolvers<void>();

    const {
      promise: precommitHandlerPromise,
      resolve: resolvePrecommitHandler,
      reject: rejectPrecommitHandler,
    } = promiseWithResolvers<void>();
    this.currentNavigation.rejectNavigateEvent = () => {
      event.signal.removeEventListener('abort', abortHandler);
      rejectPrecommitHandler();
      rejectHandler();
    };
    this.currentNavigation.resolveHandler = () => {
      this.currentNavigation.removeAbortListener?.();
      resolveHandler();
    };
    // Prevent unhandled promise rejections from internal promises.
    handlerPromise.catch(() => {});
    precommitHandlerPromise.catch(() => {});
    interceptOptions.handler = () => handlerPromise;

    if (this.deferredCommitSupported(event)) {
      const redirect = new Promise<
        (url: string, options: {state: unknown; history?: 'push' | 'replace'}) => void
      >((resolve) => {
        // The `precommitHandler` option is not in the standard DOM types yet
        (interceptOptions as any).precommitHandler = (controller: any) => {
          if (this.navigation.transition?.navigationType === 'traverse') {
            // TODO(atscott): Figure out correct behavior for redirecting traversals
            resolve(() => {});
          } else {
            resolve(controller.redirect.bind(controller));
          }
          return precommitHandlerPromise;
        };
      });
      // `commitUrl` is a function that will be called by the router's lifecycle
      // (e.g., in `BeforeRoutesRecognized` or `BeforeActivateRoutes` depending on `urlUpdateStrategy`)
      // to actually perform the URL change via the Navigation API.
      this.currentNavigation.commitUrl = async () => {
        this.currentNavigation.commitUrl = undefined; // Ensure it's only called once.
        const transition = this.currentNavigation.routerTransition;

        // If not skipping location change, use the `redirect` function (from `precommitHandler`'s
        // controller) to perform the URL update with the correct state and history action.
        if (transition && !transition.extras.skipLocationChange) {
          const internalPath = this.createBrowserPath(transition);
          const history =
            this.location.isCurrentPathEqualTo(internalPath) || !!transition.extras.replaceUrl
              ? 'replace'
              : 'push';
          const state = {
            ...transition.extras.state,
            ...this.generateNgRouterState(transition),
          };
          // this might be a path or an actual URL depending on the baseHref
          const pathOrUrl = this.location.prepareExternalUrl(internalPath);
          (await redirect)(pathOrUrl, {state, history});
        }
        resolvePrecommitHandler();
        // Wait for the Navigation API's own `committed` promise if available (part of transition object)
        // This ensures we respect the browser's timing for when the commit actually happens.
        return await this.navigation.transition?.committed;
      };
    }

    // Intercept the navigation event with the configured options.
    event.intercept(interceptOptions);

    // If `routerInfo` is null, this `NavigateEvent` was not triggered by one of the Router's
    // own `this.navigation.navigate()` calls. It's an external navigation (e.g., user click,
    // browser back/forward that the Navigation API surfaces). We need to inform the Router.
    if (!isTriggeredByRouterTransition) {
      this.handleNavigateEventTriggeredOutsideRouterAPIs(event);
    }
  }

  /**
   * Handles `NavigateEvent`s that were not initiated by the Angular Router's own API calls
   * (e.g., `router.navigate()`). These are typically from user interactions like back/forward
   * buttons or direct URL manipulation if the Navigation API intercepts them.
   *
   * It converts such an event into a format the Angular Router can understand and processes it
   * via the `nonRouterCurrentEntryChangeSubject`.
   *
   * @param event The `NavigateEvent` from the Navigation API.
   */
  private handleNavigateEventTriggeredOutsideRouterAPIs(event: NavigateEvent) {
    // TODO(atscott): Consider if the destination URL doesn't start with `appRootURL`.
    // Should we ignore it or not intercept in the first place?

    // Extract the application-relative path from the full destination URL.
    const path = event.destination.url.substring(this.appRootURL.length - 1);
    const state = event.destination.getState() as RestoredState | null | undefined;
    this.nonRouterCurrentEntryChangeSubject.next({path, state});
  }

  private eventAndRouterDestinationsMatch(
    navigateEvent: NavigateEvent,
    transition: RouterNavigation,
  ): boolean {
    const internalPath = this.createBrowserPath(transition);
    const eventDestination = new URL(navigateEvent.destination.url);
    // this might be a path or an actual URL depending on the baseHref
    const routerDestination = this.location.prepareExternalUrl(internalPath);
    return new URL(routerDestination, eventDestination.origin).href === eventDestination.href;
  }

  private generateNgRouterState(transition: RouterNavigation) {
    return {
      ...this.routerUrlState(transition),
      // Include router's navigationId for tracking. Required for in-memory scroll restoration
      navigationId: transition.id,
    };
  }

  private deferredCommitSupported(event: NavigateEvent): boolean {
    return (
      this.precommitHandlerSupported &&
      // Cannot defer commit if not cancelable by the Navigation API's rules.
      event.cancelable
    );
  }
}

/**
 * Attaches a no-op `.catch(() => {})` to the `committed` and `finished` promises of a
 * `NavigationResult`. This is to prevent unhandled promise rejection errors in the console
 * if the consumer of the navigation method (e.g., `router.navigate()`) doesn't explicitly
 * handle rejections on both promises. Navigations can be legitimately aborted (e.g., by a
 * subsequent navigation), and this shouldn't necessarily manifest as an unhandled error
 * if the application code doesn't specifically need to react to the `committed` promise
 * rejecting in such cases. The `finished` promise is more commonly used to determine
 * overall success/failure.
 */
function handleResultRejections(result: NavigationResult): NavigationResult {
  result.finished?.catch(() => {});
  result.committed?.catch(() => {});
  return result;
}
