/*
 * Copyright 2023 Harness, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { noop } from 'lodash-es'
import { useGet, useMutate } from 'restful-react'
import {
  Button,
  Container,
  FlexExpander,
  ButtonVariation,
  Layout,
  Text,
  ButtonSize,
  Checkbox,
  useIsMounted,
  useToaster
} from '@harnessio/uicore'
import cx from 'classnames'
import * as Diff2Html from 'diff2html'
import { Render } from 'react-jsx-match'
import { Link } from 'react-router-dom'
import { useInView } from 'react-intersection-observer'
import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui'
import { Icon } from '@harnessio/icons'
import { Color } from '@harnessio/design-system'
import { useStrings } from 'framework/strings'
import { CodeIcon, GitInfoProps } from 'utils/GitUtils'
import type { DiffFileEntry } from 'utils/types'
import { useAppContext } from 'AppContext'
import type { GitFileDiff, TypesPullReq, TypesPullReqActivity } from 'services/code'
import { CopyButton } from 'components/CopyButton/CopyButton'
import { NavigationCheck } from 'components/NavigationCheck/NavigationCheck'
import type { UseGetPullRequestInfoResult } from 'pages/PullRequest/useGetPullRequestInfo'
import { useQueryParams } from 'hooks/useQueryParams'
import { useCustomEventListener, useEventListener } from 'hooks/useEventListener'
import { useShowRequestError } from 'hooks/useShowRequestError'
import { getErrorMessage, isInViewport } from 'utils/Utils'
import { createRequestAnimationFrameTaskPool } from 'utils/Task'
import { useResizeObserver } from 'hooks/useResizeObserver'
import { useFindGitBranch } from 'hooks/useFindGitBranch'
import Config from 'Config'
import {
  DIFF2HTML_CONFIG,
  DIFF_VIEWER_HEADER_HEIGHT,
  ViewStyle,
  getFileViewedState,
  FileViewedState,
  DiffCommentItem
} from './DiffViewerUtils'
import { usePullReqComments } from './usePullReqComments'
import Collapse from '../../icons/collapse.svg'
import Expand from '../../icons/expand.svg'
import css from './DiffViewer.module.scss'

interface DiffViewerProps extends Pick<GitInfoProps, 'repoMetadata'> {
  diff: DiffFileEntry
  viewStyle: ViewStyle
  stickyTopPosition?: number
  readOnly?: boolean
  pullRequestMetadata?: TypesPullReq
  targetRef?: string
  sourceRef?: string
  commitRange?: string[]
  scrollElement: HTMLElement
  commitSHA?: string
  refetchActivities?: UseGetPullRequestInfoResult['refetchActivities']
  memorizedState: Map<string, DiffViewerExchangeState>
  fullDiffAPIPath: string
}

const DiffViewerInternal: React.FC<DiffViewerProps> = ({
  diff,
  viewStyle,
  stickyTopPosition = 0,
  readOnly,
  repoMetadata,
  pullRequestMetadata: pullReqMetadata,
  targetRef,
  sourceRef,
  commitRange,
  scrollElement,
  commitSHA,
  refetchActivities,
  memorizedState,
  fullDiffAPIPath
}) => {
  const { routes } = useAppContext()
  const { getString } = useStrings()
  const { showError } = useToaster()
  const viewedPath = useMemo(
    () => `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullReqMetadata?.number}/file-views`,
    [repoMetadata.path, pullReqMetadata?.number]
  )
  const { mutate: markViewed } = useMutate({ verb: 'PUT', path: viewedPath })
  const { mutate: unmarkViewed } = useMutate({ verb: 'DELETE', path: ({ filePath }) => `${viewedPath}/${filePath}` })

  const { path } = useQueryParams<{ path: string; commentId: string }>()
  const shouldDiffBeShownByDefault = useMemo(() => path === diff.filePath, [path, diff.filePath])
  const diffHasVeryLongLine = useMemo(
    () => diff.blocks?.some(block => block.lines?.some(line => line.content?.length > Config.MAX_TEXT_LINE_SIZE_LIMIT)),
    [diff]
  )

  // File viewed feature is only enabled if no commit range is provided (otherwise component is hidden, too)
  const [viewed, setViewed] = useState(
    commitRange?.length === 0 &&
      getFileViewedState(diff.filePath, diff.checksumAfter, diff.fileViews) === FileViewedState.VIEWED &&
      !shouldDiffBeShownByDefault
  )

  useEffect(() => {
    if (commitRange?.length === 0) {
      setViewed(getFileViewedState(diff.filePath, diff.checksumAfter, diff.fileViews) === FileViewedState.VIEWED)
    }
  }, [setViewed, diff.fileViews, diff.filePath, diff.checksumAfter, commitRange])

  const showChangedSinceLastView = useMemo(
    () =>
      !readOnly &&
      commitRange?.length === 0 &&
      getFileViewedState(diff.filePath, diff.checksumAfter, diff.fileViews) === FileViewedState.CHANGED,
    [readOnly, commitRange?.length, diff.filePath, diff.checksumAfter, diff.fileViews]
  )
  const [collapsed, setCollapsed] = useState(viewed || !!memorizedState.get(diff.filePath)?.collapsed)
  const isBinary = useMemo(() => diff.isBinary, [diff.isBinary])
  const fileUnchanged = useMemo(
    () => diff.unchangedPercentage === 100 || (diff.addedLines === 0 && diff.deletedLines === 0),
    [diff.addedLines, diff.deletedLines, diff.unchangedPercentage]
  )
  const fileDeleted = useMemo(() => diff.isDeleted, [diff.isDeleted])
  const isDiffTooLarge = useMemo(
    () => diff.addedLines + diff.deletedLines > Config.PULL_REQUEST_LARGE_DIFF_CHANGES_LIMIT,
    [diff.addedLines, diff.deletedLines]
  )
  const [renderCustomContent, setRenderCustomContent] = useState(
    !shouldDiffBeShownByDefault && (fileUnchanged || fileDeleted || isDiffTooLarge || isBinary || diffHasVeryLongLine)
  )
  const containerRef = useRef<HTMLDivElement | null>(null)
  const contentRef = useRef<HTMLDivElement>(null)
  const diff2HtmlRef = useRef<{ renderer: Diff2HtmlUI; diff: DiffFileEntry }>()
  const [dirty, setDirty] = useState(false)
  const isMounted = useIsMounted()
  const [useFullDiff, setUseFullDiff] = useState(!!memorizedState.get(diff.filePath)?.useFullDiff)
  const { ref, inView } = useInView({
    rootMargin: `500px 0px 500px 0px`,
    initialInView: true
  })
  const setContainerRef = useCallback(
    node => {
      containerRef.current = node
      ref(node)
    },
    [ref]
  )
  const contentHTML = useRef<string | null>(null)

  useResizeObserver(
    contentRef,
    useCallback(
      dom => {
        if (isMounted.current && dom) {
          dom.style.setProperty(BLOCK_HEIGHT, dom.clientHeight + 'px')
        }
      },
      [isMounted]
    )
  )

  //
  // Handling custom events sent to DiffViewer from external components/features
  // such as "jump to file", "jump to comment", etc...
  //
  useCustomEventListener<DiffViewerCustomEvent>(
    diff.filePath,
    useCallback(event => {
      const { action, commentId } = event.detail
      const containerDOM = document.getElementById(diff.containerId) as HTMLDivElement

      function scrollToComment(count = 0) {
        const commentDOM = containerDOM.querySelector(`[data-comment-id="${commentId}"]`) as HTMLDivElement

        if (!isMounted.current || count > 100) {
          return
        }

        if (commentDOM) {
          const dom = commentDOM?.parentElement?.parentElement?.parentElement?.parentElement
          if (dom) dom.lastElementChild?.scrollIntoView({ block: 'center' })
        } else {
          setTimeout(() => scrollToComment(count + 1), 100)
        }
      }

      function scrollToContainer() {
        if (!isMounted.current) return

        containerDOM.scrollIntoView({ block: 'start' })

        if (!commentId) {
          // Check to adjust scroll position to make sure content is not
          // cut off due to current scroll position
          const scrollGap = containerDOM.getBoundingClientRect().y - stickyTopPosition
          if (scrollGap < 1) {
            scrollElement.scroll({ top: (scrollElement.scrollTop || window.scrollY) + scrollGap })
          }
        } else {
          scrollToComment()
        }
      }

      switch (action) {
        case DiffViewerEvent.SCROLL_INTO_VIEW: {
          scrollToContainer()
          break
        }
      }
    }, []), // eslint-disable-line react-hooks/exhaustive-deps
    () => !!diff.filePath
  )

  const commentsHook = usePullReqComments({
    diff,
    viewStyle,
    stickyTopPosition,
    readOnly,
    repoMetadata,
    pullReqMetadata,
    targetRef,
    sourceRef,
    commitRange,
    scrollElement,
    collapsed,
    containerRef,
    contentRef,
    refetchActivities,
    setDirty,
    memorizedState
  })

  useEffect(
    function alwaysExpandDiffIfChangedSinceLastView() {
      if (showChangedSinceLastView && collapsed && !viewed) {
        setCollapsed(false)
      }
    },
    [showChangedSinceLastView, viewed] // eslint-disable-line react-hooks/exhaustive-deps
  )

  const renderDiffAndComments = useCallback(() => {
    if (!isMounted.current) return

    const fullDiff = memorizedState.get(diff.filePath)?.fullDiff
    const _diff = useFullDiff && fullDiff ? fullDiff : diff

    // Create a new diff renderer if cached diff is different from current diff
    // to ensure when new commit is selected, the diff is re-rendered correctly
    if (diff2HtmlRef.current?.diff !== _diff) {
      diff2HtmlRef.current = {
        renderer: new Diff2HtmlUI(
          contentRef.current as HTMLDivElement,
          [_diff],
          Object.assign({}, DIFF2HTML_CONFIG, { outputFormat: viewStyle })
        ),
        diff: _diff
      }
    }

    diff2HtmlRef.current.renderer.draw()
    commentsHook.current.attachAllCommentThreads()
  }, [commentsHook, diff, memorizedState, useFullDiff, viewStyle, isMounted])

  useEffect(
    function renderDiffAndCommentsIfInViewportOrSchedule() {
      let taskId = 0

      if (!renderCustomContent && !collapsed) {
        if (isInViewport(containerRef.current as Element, 1000)) {
          renderDiffAndComments()
        } else {
          taskId = scheduleTask(renderDiffAndComments)
        }
      }

      memorizedState.set(diff.filePath, { ...memorizedState.get(diff.filePath), collapsed })

      return () => cancelTask(taskId)
    },
    [collapsed, diff.filePath, memorizedState, isMounted, renderDiffAndComments, renderCustomContent]
  )

  const {
    data: fullDiffData,
    error: fullDiffError,
    loading: fullDiffLoading,
    refetch: getFullDiff,
    cancel: cancelGetFullDiff
  } = useGet<GitFileDiff[]>({
    path: fullDiffAPIPath,
    requestOptions: { headers: { Accept: 'application/json' } },
    queryParams: { include_patch: true, path: diff.filePath, range: 1 },
    lazy: !useFullDiff || !!memorizedState.get(diff.filePath)?.fullDiff
  })

  const branchInfo = useFindGitBranch(pullReqMetadata?.source_branch)

  useEffect(
    function serializeDeserializeContent() {
      const dom = contentRef.current

      if (inView) {
        if (isMounted.current && dom && contentHTML.current) {
          dom.innerHTML = contentHTML.current
          contentHTML.current = null

          // Remove all signs from the raw HTML that CommentBox was mounted so
          // it can be mounted/re-rendered again freshly
          dom.querySelectorAll('tr[data-source-line-number]').forEach(row => {
            row.removeAttribute('data-source-line-number')
            row.removeAttribute('data-comment-ids')
            row.querySelector('button[data-toggle-comment="true"]')?.remove?.()
          })
          dom.querySelectorAll('tr[data-annotated-line],tr[data-place-holder-for-line]').forEach(row => {
            row.remove?.()
          })

          // Attach comments again
          commentsHook.current.attachAllCommentThreads()
        }
      } else {
        if (isMounted.current && dom && !contentHTML.current) {
          const { clientHeight, textContent, innerHTML } = dom

          // Detach comments since they are no longer in sync in DOM as
          // all DOMs are removed
          commentsHook.current.detachAllCommentThreads()

          // Save current innerHTML
          contentHTML.current = innerHTML

          const pre = document.createElement('pre')
          pre.style.height = clientHeight + 'px'
          pre.textContent = textContent
          pre.classList.add(css.offscreenText)

          dom.textContent = ''
          dom.appendChild(pre)

          // TODO: Might be good to clean textContent a bit to not include
          // diff header info, line numbers, hunk headers, etc...
        }
      }
    },
    [inView, isMounted, commentsHook]
  )

  // Add click event listener from contentRef to handle click event on "Show Diff" button
  // This can't be done from the button itself because it got serialized / deserialized from
  // text during off-screen optimization (handler will be gone/destroyed)
  useEventListener(
    'click',
    useCallback(function showDiff(event) {
      if (((event.target as HTMLElement)?.closest('button') as HTMLElement)?.dataset?.action === ACTION_SHOW_DIFF) {
        setRenderCustomContent(false)
      }
    }, []),
    contentRef.current as HTMLDivElement
  )

  useShowRequestError(fullDiffError, 0)

  useEffect(
    function parseAndAssignFullDiff() {
      if (fullDiffData) {
        try {
          memorizedState.set(diff.filePath, {
            ...memorizedState.get(diff.filePath),
            fullDiff: Diff2Html.parse(
              window.atob((fullDiffData[0].patch as unknown as string) || ''),
              DIFF2HTML_CONFIG
            ).map(_diff => ({ ...diff, ..._diff }))[0],
            useFullDiff: true
          })

          setUseFullDiff(true)
          setRenderCustomContent(false)

          if (memorizedState.get(diff.filePath)?.collapsed) {
            setCollapsed(false)
            memorizedState.set(diff.filePath, {
              ...memorizedState.get(diff.filePath),
              collapsed: false
            })
          }
        } catch (exception) {
          showError(getErrorMessage(exception), 0)
        }
      }
    },
    [diff, diff.filePath, memorizedState, fullDiffData, showError]
  )

  useEffect(
    function adjustScrollPositionWhenCollapsingFile() {
      const containerDOM = containerRef.current as HTMLDivElement

      if (
        containerDOM &&
        !useFullDiff &&
        memorizedState.get(diff.filePath)?.useFullDiff === false &&
        !isInViewport(containerDOM)
      ) {
        if (stickyTopPosition && containerDOM.getBoundingClientRect().y - stickyTopPosition < 1) {
          containerDOM.scrollIntoView()
          scrollElement.scroll({ top: (scrollElement.scrollTop || window.scrollY) - stickyTopPosition })
        }
      }
    },
    [scrollElement, stickyTopPosition, useFullDiff, diff, memorizedState]
  )

  const toggleFullDiff = useCallback(() => {
    // If full diff is not fetched, fetch it and set useFullDiff when data arrives
    // Otherwise, toggle useFullDiff flag
    if (!memorizedState.get(diff.filePath)?.fullDiff && !fullDiffLoading) {
      cancelGetFullDiff()
      getFullDiff()
    } else {
      memorizedState.set(diff.filePath, { ...memorizedState.get(diff.filePath), useFullDiff: !useFullDiff })
      setUseFullDiff(!useFullDiff)
    }
  }, [useFullDiff, memorizedState, diff.filePath, cancelGetFullDiff, getFullDiff, fullDiffLoading])

  const ToggleFullDiffIcon = useMemo(() => (useFullDiff ? Collapse : Expand), [useFullDiff])

  return (
    <Container
      ref={setContainerRef}
      id={diff.containerId}
      className={cx(css.main, { [css.readOnly]: readOnly })}
      data-diff-file-path={diff.filePath}
      style={{ '--diff-viewer-sticky-top': `${stickyTopPosition}px` } as React.CSSProperties}>
      <Layout.Vertical>
        <Container className={css.diffHeader} height={DIFF_VIEWER_HEADER_HEIGHT}>
          <Layout.Horizontal>
            <Button
              variation={ButtonVariation.ICON}
              icon={collapsed ? 'main-chevron-right' : 'main-chevron-down'}
              size={ButtonSize.SMALL}
              onClick={() => setCollapsed(!collapsed)}
              iconProps={{
                size: 12,
                style: {
                  color: '#383946',
                  flexGrow: 1,
                  justifyContent: 'center',
                  display: 'flex'
                }
              }}
              className={css.chevron}
            />
            <Button
              variation={ButtonVariation.ICON}
              className={css.expandCollapseDiffBtn}
              onClick={toggleFullDiff}
              title={getString(useFullDiff ? 'pr.collapseFullFile' : 'pr.expandFullFile')}>
              <ToggleFullDiffIcon width="16" height="16" strokeWidth="2" />
            </Button>
            <Text
              inline
              className={css.fname}
              lineClamp={1}
              tooltipProps={{
                portalClassName: css.popover,
                className: css.fnamePopover
              }}>
              <Link
                to={routes.toCODERepository({
                  repoPath: repoMetadata.path as string,
                  gitRef: pullReqMetadata?.source_branch
                    ? branchInfo
                      ? pullReqMetadata?.source_branch
                      : pullReqMetadata?.source_sha
                    : commitSHA || '',
                  resourcePath: diff.isRename ? diff.newName : diff.filePath
                })}>
                {diff.isRename ? `${diff.oldName} -> ${diff.newName}` : diff.filePath}
              </Link>
              <CopyButton content={diff.filePath} icon={CodeIcon.Copy} size={ButtonSize.SMALL} />
            </Text>
            <Container style={{ alignSelf: 'center' }} padding={{ left: 'small' }} margin={{ right: 'small' }}>
              <Layout.Horizontal spacing="xsmall">
                <Render when={diff.addedLines || diff.isNew}>
                  <Text tag="span" className={css.addedLines}>
                    +{diff.addedLines || 0}
                  </Text>
                </Render>
                <Render when={diff.deletedLines || diff.isDeleted}>
                  <Text tag="span" className={css.deletedLines}>
                    -{diff.deletedLines || 0}
                  </Text>
                </Render>
              </Layout.Horizontal>
            </Container>
            <FlexExpander />

            <Render when={showChangedSinceLastView}>
              <Container>
                <Text className={css.fileChanged}>{getString('changedSinceLastView')}</Text>
              </Container>
            </Render>

            <Render when={!readOnly}>
              <Container>
                <Layout.Horizontal spacing="xsmall" flex>
                  {fullDiffLoading && (
                    <Icon name="steps-spinner" color={Color.PRIMARY_7} margin={{ right: 'xsmall' }} />
                  )}
                  <Render when={commitRange?.length === 0}>
                    <label className={css.viewLabel}>
                      <Checkbox
                        checked={viewed}
                        onChange={async () => {
                          if (viewed) {
                            setViewed(false)
                            setCollapsed(false)

                            // update local data first
                            diff.fileViews?.delete(diff.filePath)

                            // best effort attempt to recflect on server (swallow exception - user still sees correct data locally)
                            await unmarkViewed(null, { pathParams: { filePath: diff.filePath } }).catch(noop)
                          } else {
                            setViewed(true)
                            setCollapsed(true)

                            // update local data first
                            // we could wait for server response for the guaranteed correct SHA, but this is non-crucial data so it's okay
                            diff.fileViews?.set(diff.filePath, diff.checksumAfter || 'unknown')

                            // best effort attempt to recflect on server (swallow exception - user still sees correct data locally)
                            await markViewed(
                              {
                                path: diff.filePath,
                                commit_sha: pullReqMetadata?.source_sha
                              },
                              {}
                            ).catch(noop)
                          }
                        }}
                      />
                      {getString('viewed')}
                    </label>
                  </Render>
                </Layout.Horizontal>
              </Container>
            </Render>
          </Layout.Horizontal>
        </Container>

        <Container id={diff.contentId} data-path={diff.filePath} className={css.diffContent} ref={contentRef}>
          {/* Note: This parent container is needed to make sure "Show Diff" work correctly 
            with content converted between textContent and innerHTML */}
          <Container>
            <Render when={renderCustomContent && !collapsed}>
              <Container height={200} flex={{ align: 'center-center' }}>
                <Layout.Vertical padding="xlarge" style={{ alignItems: 'center' }}>
                  <Render when={fileDeleted || isDiffTooLarge || diffHasVeryLongLine}>
                    <Button variation={ButtonVariation.LINK} data-action={ACTION_SHOW_DIFF}>
                      {getString('pr.showDiff')}
                    </Button>
                  </Render>
                  <Text>
                    {getString(
                      fileDeleted
                        ? 'pr.fileDeleted'
                        : isDiffTooLarge || diffHasVeryLongLine
                        ? 'pr.diffTooLarge'
                        : isBinary
                        ? 'pr.fileBinary'
                        : 'pr.fileUnchanged'
                    )}
                  </Text>
                </Layout.Vertical>
              </Container>
            </Render>
          </Container>
        </Container>
      </Layout.Vertical>
      <NavigationCheck when={dirty} />
    </Container>
  )
}

const BLOCK_HEIGHT = '--block-height'
const ACTION_SHOW_DIFF = 'showDiff'

export enum DiffViewerEvent {
  SCROLL_INTO_VIEW = 'scrollIntoView'
}

export interface DiffViewerCustomEvent {
  action: DiffViewerEvent
  commentId?: string
}

export interface DiffViewerExchangeState {
  collapsed?: boolean
  useFullDiff?: boolean
  fullDiff?: DiffFileEntry
  comments?: Map<number, CommentRestorationTrackingState>
  commentsVisibilityAtLineNumber?: Map<number, boolean>
}

export interface CommentRestorationTrackingState extends DiffCommentItem<TypesPullReqActivity> {
  uncommittedText?: string
  showReplyPlaceHolder?: boolean
  uncommittedEditComments?: Map<number, string>
}

const { scheduleTask, cancelTask } = createRequestAnimationFrameTaskPool()

export const DiffViewer = React.memo(DiffViewerInternal)
