// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.tab;

import android.app.Activity;
import android.os.Handler;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.ObserverList.RewindableIterator;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.AppHooks;
import org.chromium.chrome.browser.SwipeRefreshHandler;
import org.chromium.chrome.browser.app.bluetooth.BluetoothNotificationService;
import org.chromium.chrome.browser.app.usb.UsbNotificationService;
import org.chromium.chrome.browser.bluetooth.BluetoothNotificationManager;
import org.chromium.chrome.browser.display_cutout.DisplayCutoutTabHelper;
import org.chromium.chrome.browser.feedback.HelpAndFeedbackLauncherImpl;
import org.chromium.chrome.browser.media.MediaCaptureNotificationServiceImpl;
import org.chromium.chrome.browser.policy.PolicyAuditor;
import org.chromium.chrome.browser.policy.PolicyAuditor.AuditEvent;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.ui.native_page.NativePage;
import org.chromium.chrome.browser.usb.UsbNotificationManager;
import org.chromium.content_public.browser.GlobalRenderFrameHostId;
import org.chromium.content_public.browser.LifecycleState;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.net.NetError;
import org.chromium.ui.mojom.VirtualKeyboardMode;
import org.chromium.url.GURL;

/**
 * WebContentsObserver used by Tab.
 */
public class TabWebContentsObserver extends TabWebContentsUserData {
    // URL didFailLoad error code. Should match the value in net_error_list.h.
    public static final int BLOCKED_BY_ADMINISTRATOR = -22;

    private static final Class<TabWebContentsObserver> USER_DATA_KEY = TabWebContentsObserver.class;

    /** Used for logging. */
    private static final String TAG = "TabWebContentsObs";

    private final TabImpl mTab;
    private final ObserverList<Callback<WebContents>> mInitObservers = new ObserverList<>();
    private WebContentsObserver mObserver;
    private GURL mLastUrl;

    public static TabWebContentsObserver from(Tab tab) {
        TabWebContentsObserver observer = get(tab);
        if (observer == null) {
            observer = new TabWebContentsObserver(tab);
            tab.getUserDataHost().setUserData(USER_DATA_KEY, observer);
        }
        return observer;
    }

    @VisibleForTesting
    public static TabWebContentsObserver get(Tab tab) {
        return tab.getUserDataHost().getUserData(USER_DATA_KEY);
    }

    private TabWebContentsObserver(Tab tab) {
        super(tab);
        mTab = (TabImpl) tab;
    }

    /**
     * Adds an observer triggered when |initWebContents| is invoked.
     * <p>A newly created tab adding this observer misses the event because
     * |TabObserver.onContentChanged| -&gt; |TabWebContentsObserver.initWebContents|
     * occurs before the observer is added. Manually trigger it here.
     * @param observer Observer to add.
     */
    public void addInitWebContentsObserver(Callback<WebContents> observer) {
        if (mInitObservers.addObserver(observer) && mTab.getWebContents() != null) {
            observer.onResult(mTab.getWebContents());
        }
    }

    /**
     * Remove the InitWebContents observer from the list.
     */
    public void removeInitWebContentsObserver(Callback<WebContents> observer) {
        mInitObservers.removeObserver(observer);
    }

    @Override
    public void destroyInternal() {
        mInitObservers.clear();
    }

    @Override
    public void initWebContents(WebContents webContents) {
        mObserver = new Observer(webContents);

        for (Callback<WebContents> callback : mInitObservers) callback.onResult(webContents);
    }

    @Override
    public void cleanupWebContents(WebContents webContents) {
        if (mObserver != null) {
            mObserver.destroy();
            mObserver = null;
        }
    }

    public void simulateRendererKilledForTesting() {
        if (mObserver != null) mObserver.renderProcessGone();
    }

    private class Observer extends WebContentsObserver {
        public Observer(WebContents webContents) {
            super(webContents);
        }

        @Override
        public void renderProcessGone() {
            Log.i(TAG,
                    "renderProcessGone() for tab id: " + mTab.getId()
                            + ", already needs reload: " + Boolean.toString(mTab.needsReload()));
            // Do nothing for subsequent calls that happen while the tab remains crashed. This
            // can occur when the tab is in the background and it shares the renderer with other
            // tabs. After the renderer crashes, the WebContents of its tabs are still around
            // and they still share the RenderProcessHost. When one of the tabs reloads spawning
            // a new renderer for the shared RenderProcessHost and the new renderer crashes
            // again, all tabs sharing this renderer will be notified about the crash (including
            // potential background tabs that did not reload yet).
            if (mTab.needsReload() || SadTab.isShowing(mTab)) return;

            // If the renderer crashes for a native page, it can be ignored as we never show that
            // content. The URL check is done in addition to the isNativePage to ensure a navigation
            // off the native page did not result in the crash.
            if (mTab.isNativePage()
                    && NativePage.isNativePageUrl(mTab.getUrl(), mTab.isIncognito())) {
                return;
            }

            int activityState = ApplicationStatus.getStateForActivity(
                    mTab.getWindowAndroid().getActivity().get());
            if (mTab.isHidden() || activityState == ActivityState.PAUSED
                    || activityState == ActivityState.STOPPED
                    || activityState == ActivityState.DESTROYED) {
                // The tab crashed in background or was killed by the OS out-of-memory killer.
                mTab.setNeedsReload();
            } else {
                // TODO(crbug.com/1074078): Remove the Handler and call SadTab directly when
                // WebContentsObserverProxy observers' iterator concurrency issue is fixed.
                // Showing the SadTab will cause the content view hosting WebContents to lose focus.
                // Post the show in order to avoid immediately triggering
                // {@link WebContentsObserver#onWebContentsLostFocus}. This will ensure all
                // observers in {@link WebContentsObserverProxy} receive callbacks for
                // {@link WebContentsObserver#renderProcessGone} first.
                SadTab sadTab = SadTab.from(mTab);
                (new Handler()).post(() -> {
                    sadTab.show(mTab.getThemedApplicationContext(),
                            /* suggestionAction= */ () -> {
                                Activity activity = mTab.getWindowAndroid().getActivity().get();
                                assert activity != null;
                                HelpAndFeedbackLauncherImpl
                                        .getForProfile(
                                                Profile.fromWebContents(mTab.getWebContents()))
                                        .show(activity,
                                                activity.getString(R.string.help_context_sad_tab),
                                                null);
                            },

                            /* buttonAction= */ () -> {
                                if (sadTab.showSendFeedbackView()) {
                                    mTab.getActivity().startHelpAndFeedback(mTab.getUrl().getSpec(),
                                            "MobileSadTabFeedback",
                                            Profile.fromWebContents(mTab.getWebContents()));
                                } else {
                                    mTab.reload();
                                }
                            });
                });
                // This is necessary to correlate histogram data with stability counts.
                RecordHistogram.recordBooleanHistogram("Stability.Android.RendererCrash", true);
            }

            mTab.handleTabCrash();
        }

        @Override
        public void didFinishLoadInPrimaryMainFrame(GlobalRenderFrameHostId frameId, GURL url,
                boolean isKnownValid, @LifecycleState int frameLifecycleState) {
            assert isKnownValid;
            if (frameLifecycleState == LifecycleState.ACTIVE) {
                if (mTab.getNativePage() != null) {
                    mTab.pushNativePageStateToNavigationEntry();
                }
                mTab.didFinishPageLoad(url);
            }
        }

        @Override
        public void didFailLoad(boolean isInPrimaryMainFrame, int errorCode, GURL failingGurl,
                @LifecycleState int frameLifecycleState) {
            if (isInPrimaryMainFrame) {
                mTab.didFailPageLoad(errorCode);
            }

            recordErrorInPolicyAuditor(failingGurl.getSpec(), "net error: " + errorCode, errorCode);
        }

        private void recordErrorInPolicyAuditor(
                String failingUrl, String description, int errorCode) {
            assert description != null;

            PolicyAuditor auditor = AppHooks.get().getPolicyAuditor();
            if (auditor != null) {
                auditor.notifyAuditEvent(ContextUtils.getApplicationContext(),
                        AuditEvent.OPEN_URL_FAILURE, failingUrl, description);
                if (errorCode == BLOCKED_BY_ADMINISTRATOR) {
                    auditor.notifyAuditEvent(ContextUtils.getApplicationContext(),
                            AuditEvent.OPEN_URL_BLOCKED, failingUrl, "");
                }
            }
        }

        @Override
        public void titleWasSet(String title) {
            mTab.updateTitle(title);
        }

        @Override
        public void didStartNavigationInPrimaryMainFrame(NavigationHandle navigation) {
            if (!navigation.isSameDocument()) {
                mTab.didStartPageLoad(navigation.getUrl());
            }

            RewindableIterator<TabObserver> observers = mTab.getTabObservers();
            while (observers.hasNext()) {
                observers.next().onDidStartNavigationInPrimaryMainFrame(mTab, navigation);
            }
        }

        @Override
        public void didRedirectNavigation(NavigationHandle navigation) {
            RewindableIterator<TabObserver> observers = mTab.getTabObservers();
            while (observers.hasNext()) {
                observers.next().onDidRedirectNavigation(mTab, navigation);
            }
        }

        @Override
        public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigation) {
            RewindableIterator<TabObserver> observers = mTab.getTabObservers();
            while (observers.hasNext()) {
                observers.next().onDidFinishNavigationInPrimaryMainFrame(mTab, navigation);
            }

            if (navigation.errorCode() != NetError.OK) {
                mTab.didFailPageLoad(navigation.errorCode());
            }
            mLastUrl = navigation.getUrl();

            if (!navigation.hasCommitted()) return;

            mTab.updateTitle();
            mTab.handleDidFinishNavigation(navigation.getUrl(), navigation.pageTransition());
            mTab.setIsShowingErrorPage(navigation.isErrorPage());

            // TODO(crbug.com/1434461) remove this call. onUrlUpdated should have been called
            // by NotifyNavigationStateChanged, which is always called before didFinishNavigation
            observers.rewind();
            while (observers.hasNext()) {
                observers.next().onUrlUpdated(mTab);
            }

            // Stop swipe-to-refresh animation.
            SwipeRefreshHandler handler = SwipeRefreshHandler.get(mTab);
            if (handler != null) handler.didStopRefreshing();

            // TODO(crbug.com/1434461) add this here to clear LocationBarModel's cache for
            // being in a same site navigation. Remove this call when the onUrlUpdated call
            // above is removed.
            observers.rewind();
            while (observers.hasNext()) {
                observers.next().onDidFinishNavigationEnd();
            }
        }

        @Override
        public void loadProgressChanged(float progress) {
            if (!mTab.isLoading()) return;
            mTab.notifyLoadProgress(progress);
        }

        @Override
        public void didFirstVisuallyNonEmptyPaint() {
            RewindableIterator<TabObserver> observers = mTab.getTabObservers();
            while (observers.hasNext()) {
                observers.next().didFirstVisuallyNonEmptyPaint(mTab);
            }
        }

        @Override
        public void didChangeThemeColor() {
            mTab.updateThemeColor(mTab.getWebContents().getThemeColor());
        }

        @Override
        public void navigationEntriesDeleted() {
            mTab.notifyNavigationEntriesDeleted();
        }

        @Override
        public void viewportFitChanged(@WebContentsObserver.ViewportFitType int value) {
            DisplayCutoutTabHelper.from(mTab).setViewportFit(value);
        }

        @Override
        public void virtualKeyboardModeChanged(@VirtualKeyboardMode.EnumType int mode) {
            RewindableIterator<TabObserver> observers = mTab.getTabObservers();
            while (observers.hasNext()) {
                observers.next().onVirtualKeyboardModeChanged(mTab, mode);
            }
        }

        @Override
        public void destroy() {
            MediaCaptureNotificationServiceImpl.updateMediaNotificationForTab(
                    ContextUtils.getApplicationContext(), mTab.getId(), null, mLastUrl);
            BluetoothNotificationManager.updateBluetoothNotificationForTab(
                    ContextUtils.getApplicationContext(), BluetoothNotificationService.class,
                    mTab.getId(), null, mLastUrl, mTab.isIncognito());
            UsbNotificationManager.updateUsbNotificationForTab(ContextUtils.getApplicationContext(),
                    UsbNotificationService.class, mTab.getId(), null, mLastUrl, mTab.isIncognito());
            super.destroy();
        }
    }
}
