import { createHoverifier, findPositionsFromEvents, HoverOverlay, } from '@sourcegraph/codeintellify';
import { getCodeElementsInRange, locateTarget } from '@sourcegraph/codeintellify/lib/token_position';
import { isEqual, pick } from 'lodash';
import * as React from 'react';
import { Link } from 'react-router-dom';
import { combineLatest, fromEvent, merge, Subject, Subscription } from 'rxjs';
import { catchError, distinctUntilChanged, filter, map, share, switchMap, withLatestFrom } from 'rxjs/operators';
import { getDecorations, getHover, getJumpURL } from '../../backend/features';
import { isDiscussionsEnabled } from '../../discussions';
import { eventLogger } from '../../tracking/eventLogger';
import { asError, isErrorLike } from '../../util/errors';
import { isDefined, propertyIsDefined } from '../../util/types';
import { parseHash, toPositionOrRangeHash } from '../../util/url';
import { BlameLine } from './blame/BlameLine';
import { DiscussionsGutterOverlay } from './discussions/DiscussionsGutterOverlay';
import { LineDecorationAttachment } from './LineDecorationAttachment';
/**
 * toPortalID builds an ID that will be used for the blame portal containers.
 */
const toPortalID = (line) => `blame-portal-${line}`;
const logTelemetryEvent = (event, data) => eventLogger.log(event, data);
const LinkComponent = (props) => React.createElement(Link, Object.assign({}, props));
const domFunctions = {
    getCodeElementFromTarget: (target) => {
        // If the target is part of the blame annotation (a.blame or span.blame__contents), return null.
        if (target.classList.contains('blame') ||
            target.classList.contains('blame__contents') ||
            target.classList.contains('line-decoration-attachment') ||
            target.classList.contains('line-decoration-attachment__contents')) {
            return null;
        }
        const row = target.closest('tr');
        if (!row) {
            return null;
        }
        return row.cells[1];
    },
    getCodeElementFromLineNumber: (codeView, line) => {
        const table = codeView.firstElementChild;
        const row = table.rows[line - 1];
        if (!row) {
            return null;
        }
        return row.cells[1];
    },
    getLineNumberFromCodeElement: (codeCell) => {
        const row = codeCell.closest('tr');
        if (!row) {
            throw new Error('Could not find closest row for codeCell');
        }
        const numberCell = row.cells[0];
        if (!numberCell || !numberCell.dataset.line) {
            throw new Error('Could not find line number');
        }
        return parseInt(numberCell.dataset.line, 10);
    },
};
export class Blob extends React.Component {
    constructor(props) {
        super(props);
        /** Emits with the latest Props on every componentDidUpdate and on componentDidMount */
        this.componentUpdates = new Subject();
        /** Emits whenever the ref callback for the code element is called */
        this.codeViewElements = new Subject();
        this.nextCodeViewElement = (element) => this.codeViewElements.next(element);
        /** Emits whenever the ref callback for the blob element is called */
        this.blobElements = new Subject();
        this.nextBlobElement = (element) => this.blobElements.next(element);
        /** Emits whenever the ref callback for the hover element is called */
        this.hoverOverlayElements = new Subject();
        this.nextOverlayElement = (element) => this.hoverOverlayElements.next(element);
        /** Emits when the go to definition button was clicked */
        this.goToDefinitionClicks = new Subject();
        this.nextGoToDefinitionClick = (event) => this.goToDefinitionClicks.next(event);
        /** Emits when the close button was clicked */
        this.closeButtonClicks = new Subject();
        this.nextCloseButtonClick = (event) => this.closeButtonClicks.next(event);
        /** Subscriptions to be disposed on unmout */
        this.subscriptions = new Subscription();
        this.state = {
            blameLineIDs: {},
        };
        /** Emits parsed positions found in the URL */
        const locationPositions = this.componentUpdates.pipe(map(props => parseHash(props.location.hash)), distinctUntilChanged((a, b) => isEqual(a, b)), share());
        const hoverifier = createHoverifier({
            closeButtonClicks: this.closeButtonClicks,
            goToDefinitionClicks: this.goToDefinitionClicks,
            hoverOverlayElements: this.hoverOverlayElements,
            hoverOverlayRerenders: this.componentUpdates.pipe(withLatestFrom(this.hoverOverlayElements, this.blobElements), 
            // After componentDidUpdate, the blob element is guaranteed to have been rendered
            map(([, hoverOverlayElement, blobElement]) => ({ hoverOverlayElement, relativeElement: blobElement })), 
            // Can't reposition HoverOverlay if it wasn't rendered
            filter(propertyIsDefined('hoverOverlayElement'))),
            pushHistory: path => this.props.history.push(path),
            logTelemetryEvent,
            fetchHover: position => getHover(this.getLSPTextDocumentPositionParams(position), this.props),
            fetchJumpURL: position => getJumpURL(this.getLSPTextDocumentPositionParams(position), this.props),
        });
        this.subscriptions.add(hoverifier);
        const resolveContext = () => ({
            repoPath: this.props.repoPath,
            rev: this.props.rev,
            commitID: this.props.commitID,
            filePath: this.props.filePath,
        });
        this.subscriptions.add(hoverifier.hoverify({
            positionEvents: this.codeViewElements.pipe(filter(isDefined), findPositionsFromEvents(domFunctions)),
            positionJumps: locationPositions.pipe(withLatestFrom(this.codeViewElements, this.blobElements), map(([position, codeView, scrollElement]) => ({
                position,
                // locationPositions is derived from componentUpdates,
                // so these elements are guaranteed to have been rendered.
                codeView: codeView,
                scrollElement: scrollElement,
            }))),
            resolveContext,
            dom: domFunctions,
        }));
        this.subscriptions.add(hoverifier.hoverStateUpdates.subscribe(update => {
            this.setState(update);
        }));
        // When clicking a line, update the URL (which will in turn trigger a highlight of the line)
        this.subscriptions.add(this.codeViewElements
            .pipe(filter(isDefined), switchMap(codeView => fromEvent(codeView, 'click')), 
        // Ignore click events caused by the user selecting text
        filter(() => window.getSelection().toString() === ''))
            .subscribe(event => {
            // Prevent selecting text on shift click (click+drag to select will still work)
            // Note that this is only called if the selection was empty initially (see above),
            // so this only clears a selection caused by this click.
            window.getSelection().removeAllRanges();
            const position = locateTarget(event.target, domFunctions);
            let hash;
            if (position &&
                event.shiftKey &&
                this.state.selectedPosition &&
                this.state.selectedPosition.line !== undefined) {
                hash = toPositionOrRangeHash({
                    range: {
                        start: {
                            line: Math.min(this.state.selectedPosition.line, position.line),
                        },
                        end: {
                            line: Math.max(this.state.selectedPosition.line, position.line),
                        },
                    },
                });
            }
            else {
                hash = toPositionOrRangeHash({ position });
            }
            if (!hash.startsWith('#')) {
                hash = '#' + hash;
            }
            this.props.history.push(Object.assign({}, this.props.location, { hash }));
        }));
        // LOCATION CHANGES
        this.subscriptions.add(locationPositions.pipe(withLatestFrom(this.codeViewElements)).subscribe(([position, codeView]) => {
            codeView = codeView; // locationPositions is derived from componentUpdates, so this is guaranteed to exist
            const codeCells = getCodeElementsInRange({
                codeView,
                position,
                getCodeElementFromLineNumber: domFunctions.getCodeElementFromLineNumber,
            });
            // Remove existing highlighting
            for (const selected of codeView.querySelectorAll('.selected')) {
                selected.classList.remove('selected');
            }
            for (const { line, element } of codeCells) {
                this.createBlameDomNode(line, element);
                // Highlight row
                const row = element.parentElement;
                row.classList.add('selected');
            }
            // Update overlay position for discussions gutter icon.
            if (codeCells.length > 0) {
                const blobBounds = codeView.parentElement.getBoundingClientRect();
                const row = codeCells[0].element.parentElement;
                const targetBounds = row.cells[0].getBoundingClientRect();
                const left = targetBounds.left - blobBounds.left;
                const top = targetBounds.top + codeView.parentElement.scrollTop - blobBounds.top;
                this.setState({ discussionsGutterOverlayPosition: { left, top } });
            }
        }));
        // EXPERIMENTAL: DECORATIONS
        /** Emits when the URL's target blob (repository, revision, path, and content) changes. */
        const modelChanges = this.componentUpdates.pipe(map(props => pick(props, 'repoPath', 'rev', 'commitID', 'filePath', 'mode', 'content')), distinctUntilChanged((a, b) => isEqual(a, b)), share());
        // Update the Sourcegraph extensions environment to reflect the current file.
        this.subscriptions.add(combineLatest(modelChanges, locationPositions).subscribe(([model, pos]) => {
            this.props.extensionsOnVisibleTextDocumentsChange([
                {
                    uri: `git://${model.repoPath}?${model.commitID}#${model.filePath}`,
                    languageId: model.mode,
                    text: model.content,
                },
            ]);
        }));
        // Clear the Sourcegraph extensions environment's component when the blob is no longer shown.
        this.subscriptions.add(() => this.props.extensionsOnVisibleTextDocumentsChange(null));
        /** Decorations */
        let lastModel;
        const decorations = combineLatest(modelChanges).pipe(switchMap(([model]) => {
            const modelChanged = !isEqual(model, lastModel);
            lastModel = model; // record so we can compute modelChanged
            // Only clear decorations if the model changed. If only the extensions changed, keep
            // the old decorations until the new ones are available, to avoid UI jitter.
            return merge(modelChanged ? [null] : [], getDecorations(model, this.props));
        }), share());
        this.subscriptions.add(decorations
            .pipe(catchError(error => [asError(error)]))
            .subscribe(decorationsOrError => this.setState({ decorationsOrError })));
        /** Render decorations. */
        let decoratedElements = [];
        this.subscriptions.add(combineLatest(decorations.pipe(map(decorations => decorations || []), catchError(error => {
            console.error(error);
            // Treat decorations error as empty decorations.
            return [[]];
        })), this.codeViewElements).subscribe(([decorations, codeView]) => {
            if (codeView) {
                if (decoratedElements) {
                    // Clear previous decorations.
                    for (const element of decoratedElements) {
                        element.style.backgroundColor = null;
                    }
                }
                for (const decoration of decorations) {
                    const line = decoration.range.start.line + 1;
                    const codeCell = domFunctions.getCodeElementFromLineNumber(codeView, line);
                    if (!codeCell) {
                        continue;
                    }
                    const row = codeCell.parentElement;
                    let decorated = false;
                    if (decoration.backgroundColor) {
                        row.style.backgroundColor = decoration.backgroundColor;
                        decorated = true;
                    }
                    if (decoration.border) {
                        row.style.border = decoration.border;
                        decorated = true;
                    }
                    if (decoration.borderColor) {
                        row.style.borderColor = decoration.borderColor;
                        decorated = true;
                    }
                    if (decoration.borderWidth) {
                        row.style.borderWidth = decoration.borderWidth;
                        decorated = true;
                    }
                    if (decorated) {
                        decoratedElements.push(row);
                    }
                    if (decoration.after) {
                        const codeCell = row.cells[1];
                        this.createBlameDomNode(line, codeCell);
                    }
                }
            }
            else {
                decoratedElements = [];
            }
        }));
    }
    getLSPTextDocumentPositionParams(position) {
        return {
            repoPath: position.repoPath,
            filePath: position.filePath,
            commitID: position.commitID,
            rev: position.rev,
            mode: this.props.mode,
            position,
        };
    }
    /**
     * Appends a blame portal DOM node to the given code cell if it doesn't contain one already.
     *
     * @param line 1-indexed line number
     * @param codeCell The `<td class="code">` element
     */
    createBlameDomNode(line, codeCell) {
        if (codeCell.querySelector('.blame-portal')) {
            return;
        }
        const portalNode = document.createElement('span');
        const id = toPortalID(line);
        portalNode.id = id;
        portalNode.classList.add('blame-portal');
        codeCell.appendChild(portalNode);
        this.setState(state => ({
            blameLineIDs: Object.assign({}, state.blameLineIDs, { [line]: id }),
        }));
    }
    componentDidMount() {
        this.componentUpdates.next(this.props);
    }
    shouldComponentUpdate(nextProps, nextState) {
        return !isEqual(this.props, nextProps) || !isEqual(this.state, nextState);
    }
    componentDidUpdate() {
        this.componentUpdates.next(this.props);
    }
    componentWillUnmount() {
        this.subscriptions.unsubscribe();
    }
    render() {
        return (React.createElement("div", { className: `blob ${this.props.className}`, ref: this.nextBlobElement },
            React.createElement("code", { className: `blob__code ${this.props.wrapCode ? ' blob__code--wrapped' : ''} e2e-blob`, ref: this.nextCodeViewElement, dangerouslySetInnerHTML: { __html: this.props.html } }),
            this.state.hoverOverlayProps && (React.createElement(HoverOverlay, Object.assign({}, this.state.hoverOverlayProps, { logTelemetryEvent: logTelemetryEvent, linkComponent: LinkComponent, hoverRef: this.nextOverlayElement, onGoToDefinitionClick: this.nextGoToDefinitionClick, onCloseButtonClick: this.nextCloseButtonClick }))),
            this.state.selectedPosition &&
                this.state.selectedPosition.line !== undefined &&
                this.state.blameLineIDs[this.state.selectedPosition.line] && (React.createElement(BlameLine, Object.assign({ key: this.state.blameLineIDs[this.state.selectedPosition.line], portalID: this.state.blameLineIDs[this.state.selectedPosition.line], line: this.state.selectedPosition.line }, this.props))),
            this.state.decorationsOrError &&
                !isErrorLike(this.state.decorationsOrError) &&
                this.state.decorationsOrError
                    .filter(d => !!d.after && this.state.blameLineIDs[d.range.start.line + 1])
                    .map((d, i) => {
                    const line = d.range.start.line + 1;
                    return (React.createElement(LineDecorationAttachment, Object.assign({ key: this.state.blameLineIDs[line], portalID: this.state.blameLineIDs[line], line: line, attachment: d.after }, this.props)));
                }),
            isDiscussionsEnabled(this.props.configurationCascade) &&
                this.state.selectedPosition &&
                this.state.selectedPosition.line !== undefined && (React.createElement(DiscussionsGutterOverlay, Object.assign({ overlayPosition: this.state.discussionsGutterOverlayPosition, selectedPosition: this.state.selectedPosition }, this.props)))));
    }
}
//# sourceMappingURL=Blob.js.map