import Highlighter
import MarkdownUI
import QuickLook
import SwiftUI
import UIKit

@MainActor
class MarkdownPageViewController: UIViewController, UIScrollViewDelegate {
    let context: AppContext
    let scrollView = UIScrollView()
    let stack = UIStackView()
    private let spinner = UIActivityIndicatorView(style: .medium)
    var loadingTask: Task<Void, Never>?
    private lazy var moreButton = UIButton(
        configuration: .plain(),
        primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() }
    )
    private var hasMoreContent = false
    private var loadingMore = false

    init(context: AppContext) {
        self.context = context
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemGroupedBackground
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.alwaysBounceVertical = true
        scrollView.delegate = self
        scrollView.refreshControl = UIRefreshControl()
        scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
        view.addSubview(scrollView)
        stack.axis = .vertical
        stack.spacing = 12
        stack.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(stack)
        NSLayoutConstraint.activate([
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scrollView.topAnchor.constraint(equalTo: view.topAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 18),
            stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -18),
            stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 18),
            stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -18),
            stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -36),
        ])
    }

    deinit { loadingTask?.cancel() }

    func loadContent(refreshing: Bool) {}

    func loadMoreContent() {}

    func beginLoading(refreshing: Bool) {
        if !refreshing { beginNavigationLoading(spinner) }
    }

    func endLoading() {
        endNavigationLoading(spinner)
        scrollView.refreshControl?.endRefreshing()
    }

    func replaceContent(_ views: [UIView]) {
        stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
        views.forEach(stack.addArrangedSubview)
    }

    func resetPagination() {
        hasMoreContent = false
        loadingMore = false
        stack.removeArrangedSubview(moreButton)
        moreButton.removeFromSuperview()
    }

    func finishPagination(hasMore: Bool) {
        hasMoreContent = hasMore
        loadingMore = false
        guard hasMore else { return }
        var configuration = moreButton.configuration
        configuration?.title = "Pull up or tap to load more"
        configuration?.showsActivityIndicator = false
        moreButton.configuration = configuration
        moreButton.accessibilityLabel = "Load more results"
        if moreButton.superview !== stack { stack.addArrangedSubview(moreButton) }
        if moreButton.constraints.isEmpty {
            moreButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
        }
    }

    func failPagination() {
        loadingMore = false
        finishPagination(hasMore: hasMoreContent)
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard scrollView.isDragging, hasMoreContent, !loadingMore else { return }
        let bottom = max(
            -scrollView.adjustedContentInset.top,
            scrollView.contentSize.height
                + scrollView.adjustedContentInset.bottom
                - scrollView.bounds.height
        )
        if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() }
    }

    private func requestMoreContent() {
        guard hasMoreContent, !loadingMore else { return }
        loadingMore = true
        var configuration = moreButton.configuration
        configuration?.title = "Loading more…"
        configuration?.showsActivityIndicator = true
        moreButton.configuration = configuration
        loadMoreContent()
    }

    @objc private func refreshRequested() {
        loadContent(refreshing: true)
    }
}

@MainActor
final class IssueViewController: MarkdownPageViewController {
    private let owner: String
    private let repository: String
    private let number: Int64
    private var page: IssuePage?
    private var currentPage: UInt32 = 0

    init(context: AppContext, owner: String, repository: String, number: Int64) {
        self.owner = owner
        self.repository = repository
        self.number = number
        super.init(context: context)
        title = "Issue"
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        let addComment = UIBarButtonItem(
            image: context.symbol("plus.bubble"),
            style: .plain,
            target: self,
            action: #selector(addComment)
        )
        addComment.accessibilityLabel = "Add comment"
        let editIssue = UIBarButtonItem(
            image: context.symbol("pencil"),
            style: .plain,
            target: self,
            action: #selector(editIssue)
        )
        editIssue.accessibilityLabel = "Edit issue"
        navigationItem.rightBarButtonItems = [addComment, editIssue]
        loadContent(refreshing: false)
    }

    @objc private func addComment() { presentCommentEditor() }

    private func editComment(_ comment: CommentRow) { presentCommentEditor(comment) }

    private func presentCommentEditor(_ comment: CommentRow? = nil) {
        let editor = CommentEditorViewController(
            context: context,
            owner: owner,
            repository: repository,
            number: number,
            comment: comment
        ) { [weak self] in
            guard let self else { return }
            self.dismiss(animated: true) { self.loadContent(refreshing: false) }
        }
        present(UINavigationController(rootViewController: editor), animated: true)
    }

    @objc private func editIssue() {
        let editor = IssueEditorViewController(
            context: context,
            owner: owner,
            repository: repository,
            number: number
        ) { [weak self] _ in
            guard let self else { return }
            self.dismiss(animated: true) { self.loadContent(refreshing: false) }
        }
        present(UINavigationController(rootViewController: editor), animated: true)
    }

    override func loadContent(refreshing: Bool) {
        loadPage(1, refreshing: refreshing)
    }

    override func loadMoreContent() {
        loadPage(currentPage + 1, refreshing: false)
    }

    private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
        if requestedPage == 1 {
            resetPagination()
            beginLoading(refreshing: refreshing)
        }
        loadingTask?.cancel()
        loadingTask = Task {
            do {
                let result = try await context.core.issue(
                    owner: owner,
                    repository: repository,
                    number: number,
                    page: requestedPage
                )
                if requestedPage == 1 {
                    page = result
                } else {
                    page?.comments.append(contentsOf: result.comments)
                    page?.hasMore = result.hasMore
                }
                currentPage = requestedPage
                if let page {
                    replaceContent(detailViews(
                        title: page.title,
                        issueState: page.state,
                        meta: page.meta,
                        body: page.body,
                        comments: page.comments,
                        editComment: { [weak self] comment in self?.editComment(comment) },
                        milestone: page.milestone
                    ))
                    finishPagination(hasMore: page.hasMore)
                }
            } catch {
                if !Task.isCancelled {
                    show(error: error)
                    failPagination()
                }
            }
            if requestedPage == 1 { endLoading() }
        }
    }
}

@MainActor
final class PullViewController: MarkdownPageViewController {
    private let owner: String
    private let repository: String
    private let number: Int64
    private var page: PullPage?
    private var currentPage: UInt32 = 0

    init(context: AppContext, owner: String, repository: String, number: Int64) {
        self.owner = owner
        self.repository = repository
        self.number = number
        super.init(context: context)
        title = "Pull Request"
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        loadContent(refreshing: false)
    }

    override func loadContent(refreshing: Bool) {
        loadPage(1, refreshing: refreshing)
    }

    override func loadMoreContent() {
        loadPage(currentPage + 1, refreshing: false)
    }

    private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
        if requestedPage == 1 {
            resetPagination()
            beginLoading(refreshing: refreshing)
        }
        loadingTask?.cancel()
        loadingTask = Task {
            do {
                let result = try await context.core.pull(
                    owner: owner,
                    repository: repository,
                    number: number,
                    page: requestedPage
                )
                if requestedPage == 1 {
                    page = result
                } else {
                    page?.files.append(contentsOf: result.files)
                    page?.comments.append(contentsOf: result.comments)
                    page?.hasMore = result.hasMore
                }
                currentPage = requestedPage
                if let page {
                    var views = detailViews(
                        title: page.title,
                        meta: page.meta,
                        body: page.body,
                        comments: []
                    )
                    if !page.files.isEmpty {
                        views.append(sectionLabel(page.filesRef))
                        views.append(FileListView(rows: page.files) { [weak self] file in
                            guard let self else { return }
                            self.navigationController?.pushViewController(
                                DiffViewController(
                                    context: self.context,
                                    source: .pull(
                                        owner: self.owner,
                                        repository: self.repository,
                                        number: self.number,
                                        path: file.path
                                    )
                                ),
                                animated: true
                            )
                        })
                    }
                    views.append(contentsOf: commentViews(page.comments))
                    replaceContent(views)
                    finishPagination(hasMore: page.hasMore)
                }
            } catch {
                if !Task.isCancelled {
                    show(error: error)
                    failPagination()
                }
            }
            if requestedPage == 1 { endLoading() }
        }
    }
}

private final class FileListView: UITableView, UITableViewDataSource, UITableViewDelegate {
    private let rows: [FileRow]
    private let select: (FileRow) -> Void
    private var contentHeight: NSLayoutConstraint!

    init(rows: [FileRow], select: @escaping (FileRow) -> Void) {
        self.rows = rows
        self.select = select
        super.init(frame: .zero, style: .plain)
        translatesAutoresizingMaskIntoConstraints = false
        dataSource = self
        delegate = self
        isScrollEnabled = false
        rowHeight = UITableView.automaticDimension
        estimatedRowHeight = 55
        separatorInset = .zero
        backgroundColor = .systemGroupedBackground
        contentHeight = heightAnchor.constraint(equalToConstant: CGFloat(rows.count) * estimatedRowHeight)
        contentHeight.isActive = true
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func layoutSubviews() {
        super.layoutSubviews()
        if abs(contentHeight.constant - contentSize.height) > 0.5 {
            contentHeight.constant = contentSize.height
        }
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        rows.count
    }

    func tableView(
        _ tableView: UITableView,
        cellForRowAt indexPath: IndexPath
    ) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "file")
            ?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
        let row = rows[indexPath.row]
        configureTextCell(cell, title: row.path, detail: row.status)
        cell.accessoryType = .disclosureIndicator
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        select(rows[indexPath.row])
    }
}

@MainActor
final class FilesViewController: RefreshingTableViewController {
    private let context: AppContext
    private let owner: String
    private let repository: String
    private let sha: String
    private var rows: [FileRow] = []

    init(context: AppContext, owner: String, repository: String, sha: String) {
        self.context = context
        self.owner = owner
        self.repository = repository
        self.sha = sha
        super.init()
        title = "Changed Files"
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        loadContent(refreshing: false)
    }

    override func loadContent(refreshing: Bool) {
        beginLoading(refreshing: refreshing)
        loadingTask?.cancel()
        loadingTask = Task {
            do {
                rows = try await context.core.commitFiles(
                    owner: owner,
                    repository: repository,
                    sha: sha
                )
                tableView.reloadData()
                tableView.backgroundView = rows.isEmpty
                    ? EmptyBackgroundView(title: "No changed files", detail: "This commit does not contain file changes.")
                    : nil
            } catch {
                if !Task.isCancelled { show(error: error) }
            }
            endLoading()
        }
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        rows.count
    }

    override func tableView(
        _ tableView: UITableView,
        cellForRowAt indexPath: IndexPath
    ) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "file")
            ?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
        let row = rows[indexPath.row]
        configureTextCell(cell, title: row.path, detail: row.status)
        cell.accessoryType = .disclosureIndicator
        return cell
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let row = rows[indexPath.row]
        navigationController?.pushViewController(
            DiffViewController(
                context: context,
                source: .commit(
                    owner: owner,
                    repository: repository,
                    sha: sha,
                    path: row.path
                )
            ),
            animated: true
        )
    }
}

@MainActor
final class RepositoryDirectoryViewController: RefreshingTableViewController {
    private enum Mode: Int { case files, history }

    private let context: AppContext
    private let owner: String
    private let repository: String
    private let path: String
    private var mode = Mode.files
    private var rows: [RepositoryContentRow] = []
    private var history: CommitPage?
    private var currentPage: UInt32 = 0
    private var loadedFiles = false

    init(context: AppContext, owner: String, repository: String, path: String, name: String) {
        self.context = context
        self.owner = owner
        self.repository = repository
        self.path = path
        super.init()
        title = name
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
        navigationItem.prompt = title
        let modeControl = UISegmentedControl(items: ["Files", "History"])
        modeControl.selectedSegmentIndex = mode.rawValue
        modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
        modeControl.accessibilityLabel = "Directory view"
        navigationItem.titleView = modeControl
        loadContent(refreshing: false)
    }

    override func loadContent(refreshing: Bool) {
        switch mode {
        case .files: loadFiles(refreshing: refreshing)
        case .history: loadHistory(page: 1, refreshing: refreshing)
        }
    }

    override func loadMoreContent() {
        guard mode == .history else { return }
        loadHistory(page: currentPage + 1, refreshing: false)
    }

    private func loadFiles(refreshing: Bool) {
        resetPagination()
        beginLoading(refreshing: refreshing)
        loadingTask?.cancel()
        loadingTask = Task {
            do {
                let rows = try await context.core.repositoryContents(
                    owner: owner,
                    repository: repository,
                    path: path
                )
                guard !Task.isCancelled, mode == .files else {
                    endLoading()
                    return
                }
                self.rows = rows
                loadedFiles = true
                tableView.reloadData()
                updateEmptyView()
            } catch {
                if !Task.isCancelled { show(error: error) }
            }
            endLoading()
        }
    }

    private func loadHistory(page requestedPage: UInt32, refreshing: Bool) {
        if requestedPage == 1 {
            resetPagination()
            beginLoading(refreshing: refreshing)
        }
        loadingTask?.cancel()
        loadingTask = Task {
            do {
                let history = try await context.core.commits(
                    owner: owner,
                    repository: repository,
                    branch: nil,
                    path: path,
                    pages: requestedPage
                )
                guard !Task.isCancelled, mode == .history else {
                    if requestedPage == 1 { endLoading() }
                    return
                }
                self.history = history
                currentPage = requestedPage
                finishPagination(hasMore: history.hasMore)
                tableView.reloadData()
                updateEmptyView()
            } catch {
                if !Task.isCancelled {
                    show(error: error)
                    failPagination()
                }
            }
            if requestedPage == 1 { endLoading() }
        }
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        mode == .files ? rows.count : history?.commits.count ?? 0
    }

    override func tableView(
        _ tableView: UITableView,
        cellForRowAt indexPath: IndexPath
    ) -> UITableViewCell {
        switch mode {
        case .files:
            let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
                ?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
            configureRepositoryContentCell(cell, row: rows[indexPath.row])
            return cell
        case .history:
            let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
            if let history {
                cell.configure(history.commits[indexPath.row], laneCount: history.laneCount)
            }
            return cell
        }
    }

    override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        mode == .history ? 86 : UITableView.automaticDimension
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        switch mode {
        case .files:
            showRepositoryContent(
                rows[indexPath.row],
                context: context,
                owner: owner,
                repository: repository,
                navigationController: navigationController
            )
        case .history:
            guard let commit = history?.commits[indexPath.row] else { return }
            navigationController?.pushViewController(
                FilesViewController(
                    context: context,
                    owner: owner,
                    repository: repository,
                    sha: commit.sha
                ),
                animated: true
            )
        }
    }

    @objc private func modeChanged(_ sender: UISegmentedControl) {
        guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
        loadingTask?.cancel()
        endLoading()
        self.mode = mode
        resetPagination()
        tableView.backgroundView = nil
        tableView.reloadData()
        switch mode {
        case .files:
            if loadedFiles { updateEmptyView() } else { loadFiles(refreshing: false) }
        case .history:
            if let history {
                finishPagination(hasMore: history.hasMore)
                updateEmptyView()
            } else {
                loadHistory(page: 1, refreshing: false)
            }
        }
    }

    private func updateEmptyView() {
        switch mode {
        case .files:
            tableView.backgroundView = loadedFiles && rows.isEmpty
                ? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.")
                : nil
        case .history:
            tableView.backgroundView = history?.commits.isEmpty == true
                ? EmptyBackgroundView(title: "No commits", detail: "No commits affect this folder.")
                : nil
        }
    }
}

@MainActor
final class RepositoryHistoryViewController: RefreshingTableViewController {
    private let context: AppContext
    private let owner: String
    private let repository: String
    private let path: String
    private let emptyDetail: String
    private let loadingIndicator = UIActivityIndicatorView(style: .medium)
    private var page: CommitPage?
    private var currentPage: UInt32 = 0

    init(
        context: AppContext,
        owner: String,
        repository: String,
        path: String,
        emptyDetail: String
    ) {
        self.context = context
        self.owner = owner
        self.repository = repository
        self.path = path
        self.emptyDetail = emptyDetail
        super.init()
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
        loadContent(refreshing: false)
    }

    override func loadContent(refreshing: Bool) {
        loadPage(1, refreshing: refreshing)
    }

    override func loadMoreContent() {
        loadPage(currentPage + 1, refreshing: false)
    }

    private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
        if requestedPage == 1 {
            resetPagination()
            if !refreshing {
                loadingIndicator.startAnimating()
                tableView.backgroundView = loadingIndicator
            }
        }
        loadingTask?.cancel()
        loadingTask = Task {
            do {
                let page = try await context.core.commits(
                    owner: owner,
                    repository: repository,
                    branch: nil,
                    path: path,
                    pages: requestedPage
                )
                guard !Task.isCancelled else { return }
                self.page = page
                currentPage = requestedPage
                finishPagination(hasMore: page.hasMore)
                tableView.reloadData()
                updateEmptyView()
            } catch {
                if !Task.isCancelled {
                    show(error: error)
                    failPagination()
                }
            }
            if requestedPage == 1 {
                loadingIndicator.stopAnimating()
                refreshControl?.endRefreshing()
            }
        }
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        page?.commits.count ?? 0
    }

    override func tableView(
        _ tableView: UITableView,
        cellForRowAt indexPath: IndexPath
    ) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
        if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
        return cell
    }

    override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        86
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        guard let commit = page?.commits[indexPath.row] else { return }
        navigationController?.pushViewController(
            FilesViewController(
                context: context,
                owner: owner,
                repository: repository,
                sha: commit.sha
            ),
            animated: true
        )
    }

    private func updateEmptyView() {
        tableView.backgroundView = page?.commits.isEmpty == true
            ? EmptyBackgroundView(title: "No commits", detail: emptyDetail)
            : nil
    }
}

private final class CodeScrollView: UIScrollView {
    private let textView = UITextView(frame: .zero)

    override init(frame: CGRect) {
        super.init(frame: frame)
        translatesAutoresizingMaskIntoConstraints = false
        alwaysBounceVertical = true
        alwaysBounceHorizontal = true
        isDirectionalLockEnabled = true
        showsHorizontalScrollIndicator = true
        contentInsetAdjustmentBehavior = .never
        textView.textContainer.lineBreakMode = .byClipping
        textView.textContainer.widthTracksTextView = false
        textView.isEditable = false
        textView.isScrollEnabled = false
        textView.isSelectable = true
        addSubview(textView)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    func display(_ text: NSAttributedString) {
        let text = NSMutableAttributedString(attributedString: text)
        let paragraph = NSMutableParagraphStyle()
        paragraph.lineBreakMode = .byClipping
        text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length))
        textView.textStorage.setAttributedString(text)
        setNeedsLayout()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        textView.textContainer.size = CGSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        textView.layoutManager.ensureLayout(for: textView.textContainer)
        let textSize = textView.layoutManager.usedRect(for: textView.textContainer).size
        let size = CGSize(
            width: max(
                bounds.width,
                ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right)
            ),
            height: max(
                bounds.height,
                ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom)
            )
        )
        textView.frame = CGRect(origin: .zero, size: size)
        contentSize = size
    }
}

@MainActor
final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource {
    private let context: AppContext
    private let owner: String
    private let repository: String
    private let path: String
    private let spinner = UIActivityIndicatorView(style: .medium)
    private let modeControl = UISegmentedControl()
    private var loadingTask: Task<Void, Never>?
    private var page: RepositoryFilePage?
    private var previewURL: URL?
    private var fileLanguage: String?
    private var historyController: RepositoryHistoryViewController?
    private var displayedController: UIViewController?
    private var displayedView: UIView?

    init(context: AppContext, owner: String, repository: String, path: String, name: String) {
        self.context = context
        self.owner = owner
        self.repository = repository
        self.path = path
        super.init(nibName: nil, bundle: nil)
        title = name
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        beginNavigationLoading(spinner)
        loadingTask = Task {
            do {
                let page = try await context.core.repositoryFile(
                    owner: owner,
                    repository: repository,
                    path: path
                )
                guard !Task.isCancelled else { return }
                self.page = page
                title = page.name
                fileLanguage = page.language.isEmpty ? nil : page.language
                configureModes(for: page)
            } catch {
                if !Task.isCancelled { show(error: error) }
            }
            endNavigationLoading(spinner)
        }
    }

    deinit {
        loadingTask?.cancel()
        if let previewURL { try? FileManager.default.removeItem(at: previewURL) }
    }

    func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
        previewURL == nil ? 0 : 1
    }

    func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
        previewURL! as NSURL
    }

    private func configureModes(for page: RepositoryFilePage) {
        navigationItem.prompt = title
        modeControl.removeAllSegments()
        let modes = page.kind == "markdown"
            ? ["Preview", "Source", "History"]
            : ["Content", "History"]
        for (index, mode) in modes.enumerated() {
            modeControl.insertSegment(withTitle: mode, at: index, animated: false)
        }
        modeControl.selectedSegmentIndex = 0
        modeControl.addTarget(self, action: #selector(fileModeChanged(_:)), for: .valueChanged)
        modeControl.accessibilityLabel = "File view"
        navigationItem.titleView = modeControl
        showContent(page, mode: modes[0])
    }

    @objc private func fileModeChanged(_ sender: UISegmentedControl) {
        guard let page, let mode = sender.titleForSegment(at: sender.selectedSegmentIndex) else { return }
        showContent(page, mode: mode)
    }

    private func showContent(_ page: RepositoryFilePage, mode: String) {
        switch mode {
        case "Preview": showMarkdownPreview(page.text)
        case "Source": showSource(page.text)
        case "Content" where page.kind == "source": showSource(page.text)
        case "Content":
            do {
                try showQuickLook(page.data, name: page.name)
            } catch {
                show(error: error)
            }
        case "History": showHistory()
        default: break
        }
    }

    private func showHistory() {
        removeDisplayedView()
        let history = historyController ?? RepositoryHistoryViewController(
            context: context,
            owner: owner,
            repository: repository,
            path: path,
            emptyDetail: "No commits affect this file."
        )
        historyController = history
        addChild(history)
        history.view.frame = view.bounds
        history.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        displayedController = history
        displayedView = history.view
        view.addSubview(history.view)
        history.didMove(toParent: self)
    }

    private func showMarkdownPreview(_ source: String) {
        removeDisplayedView()
        let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source))
        addChild(preview)
        preview.view.frame = view.bounds
        preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        displayedController = preview
        displayedView = preview.view
        view.addSubview(preview.view)
        preview.didMove(toParent: self)
    }

    private func showSource(_ source: String) {
        removeDisplayedView()
        let highlighter = Highlighter()
        highlighter?.setTheme(
            traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light"
        )
        highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular))
        let highlighted = highlighter?.highlight(source, as: fileLanguage)
            ?? NSAttributedString(string: source, attributes: [
                .font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular),
                .foregroundColor: UIColor.label,
            ])
        let codeView = CodeScrollView()
        codeView.display(highlighted)
        displayedView = codeView
        view.addSubview(codeView)
        NSLayoutConstraint.activate([
            codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
        ])
    }

    private func removeDisplayedView() {
        displayedController?.willMove(toParent: nil)
        displayedView?.removeFromSuperview()
        displayedController?.removeFromParent()
        displayedController = nil
        displayedView = nil
    }

    private func showQuickLook(_ data: Data, name: String) throws {
        removeDisplayedView()
        if previewURL == nil {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("\(UUID().uuidString)-\(name)")
            try data.write(to: url, options: .atomic)
            previewURL = url
        }
        let preview = QLPreviewController()
        preview.dataSource = self
        addChild(preview)
        preview.view.frame = view.bounds
        preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        displayedController = preview
        displayedView = preview.view
        view.addSubview(preview.view)
        preview.didMove(toParent: self)
    }
}

struct RepositoryMarkdownPreview: View {
    let source: String

    var body: some View {
        ScrollView {
            Markdown(source)
                .markdownTheme(.gitHub)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
        }
        .background(Color(uiColor: .systemBackground))
    }
}

enum DiffSource {
    case commit(owner: String, repository: String, sha: String, path: String)
    case pull(owner: String, repository: String, number: Int64, path: String)
}

@MainActor
final class DiffViewController: UIViewController {
    private let context: AppContext
    private let source: DiffSource
    private let codeView = CodeScrollView()
    private let spinner = UIActivityIndicatorView(style: .medium)
    private var loadingTask: Task<Void, Never>?

    init(context: AppContext, source: DiffSource) {
        self.context = context
        self.source = source
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        codeView.refreshControl = UIRefreshControl()
        codeView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged)
        view.addSubview(codeView)
        NSLayoutConstraint.activate([
            codeView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            codeView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            codeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            codeView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
        ])
        beginNavigationLoading(spinner)
        reload()
    }

    deinit { loadingTask?.cancel() }

    @objc private func reload() {
        loadingTask?.cancel()
        loadingTask = Task {
            do {
                let page: DiffPage
                switch source {
                case let .commit(owner, repository, sha, path):
                    page = try await context.core.commitDiff(
                        owner: owner,
                        repository: repository,
                        sha: sha,
                        path: path
                    )
                case let .pull(owner, repository, number, path):
                    page = try await context.core.pullDiff(
                        owner: owner,
                        repository: repository,
                        number: number,
                        path: path
                    )
                }
                title = page.title
                codeView.display(diffText(page))
            } catch {
                if !Task.isCancelled { show(error: error) }
            }
            endNavigationLoading(spinner)
            codeView.refreshControl?.endRefreshing()
        }
    }

    private func diffText(_ page: DiffPage) -> NSAttributedString {
        let output = NSMutableAttributedString()
        let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
        for line in page.lines {
            let text = String(format: "%4@ %4@  %@\n", line.oldNumber, line.newNumber, line.text)
            let color: UIColor
            switch line.kind {
            case "addition": color = UIColor.systemGreen.withAlphaComponent(0.16)
            case "removal": color = UIColor.systemRed.withAlphaComponent(0.16)
            case "hunk": color = UIColor.systemBlue.withAlphaComponent(0.14)
            case "header": color = UIColor.systemGray.withAlphaComponent(0.14)
            default: color = .clear
            }
            output.append(NSAttributedString(string: text, attributes: [
                .font: font,
                .foregroundColor: UIColor.label,
                .backgroundColor: color,
            ]))
        }
        return output
    }
}

private func detailViews(
    title: String,
    issueState: String? = nil,
    meta: String,
    body: String,
    comments: [CommentRow],
    editComment: ((CommentRow) -> Void)? = nil,
    milestone: String = ""
) -> [UIView] {
    let titleLabel = UILabel()
    titleLabel.text = title
    titleLabel.font = .preferredFont(forTextStyle: .title1)
    titleLabel.numberOfLines = 0
    let metaLabel = UILabel()
    metaLabel.text = meta
    metaLabel.font = .preferredFont(forTextStyle: .subheadline)
    metaLabel.textColor = .secondaryLabel
    metaLabel.numberOfLines = 0
    let bodyView = markdownView(body)
    var views: [UIView] = [issueState.map { issueTitle(titleLabel, state: $0) } ?? titleLabel, metaLabel]
    if !milestone.isEmpty {
        let milestoneLabel = UILabel()
        milestoneLabel.attributedText = symbolText(
            "flag.fill",
            text: milestone,
            font: .preferredFont(forTextStyle: .subheadline),
            color: .secondaryLabel
        )
        milestoneLabel.accessibilityLabel = "Milestone \(milestone)"
        views.append(milestoneLabel)
    }
    return views + [separator(), bodyView] + commentViews(comments, editComment: editComment)
}

private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView {
    let icon = UIImageView()
    configureIssueStateIcon(icon, state: state, textStyle: .title1)
    let stack = UIStackView(arrangedSubviews: [icon, titleLabel])
    stack.axis = .horizontal
    stack.alignment = .firstBaseline
    stack.spacing = 8
    return stack
}

private func commentViews(
    _ comments: [CommentRow],
    editComment: ((CommentRow) -> Void)? = nil
) -> [UIView] {
    guard !comments.isEmpty else { return [] }
    var views: [UIView] = [sectionLabel("Comments")]
    for comment in comments {
        let author = UILabel()
        author.text = comment.author
        author.font = .preferredFont(forTextStyle: .headline)
        var headerViews: [UIView] = [author, UIView()]
        if comment.canEdit, let editComment {
            var configuration = UIButton.Configuration.plain()
            configuration.image = UIImage(systemName: "pencil")
            configuration.contentInsets = .zero
            let button = UIButton(
                configuration: configuration,
                primaryAction: UIAction { _ in editComment(comment) }
            )
            button.accessibilityLabel = "Edit comment by \(comment.author)"
            button.widthAnchor.constraint(equalToConstant: 44).isActive = true
            button.heightAnchor.constraint(equalToConstant: 44).isActive = true
            headerViews.append(button)
        }
        let header = UIStackView(arrangedSubviews: headerViews)
        header.alignment = .center
        let date = UILabel()
        date.text = comment.meta
        date.font = .preferredFont(forTextStyle: .caption1)
        date.textColor = .tertiaryLabel
        let stack = UIStackView(arrangedSubviews: [header, markdownView(comment.body), date, separator()])
        stack.axis = .vertical
        stack.spacing = 6
        views.append(stack)
    }
    return views
}

private func markdownView(_ source: String) -> UITextView {
    let view = UITextView()
    view.attributedText = markdown(source)
    view.isEditable = false
    view.isSelectable = true
    view.isScrollEnabled = false
    view.backgroundColor = .clear
    view.textContainerInset = .zero
    view.textContainer.lineFragmentPadding = 0
    view.adjustsFontForContentSizeCategory = true
    return view
}

private func sectionLabel(_ text: String) -> UILabel {
    let label = UILabel()
    label.text = text
    label.font = .preferredFont(forTextStyle: .headline)
    return label
}

private func separator() -> UIView {
    let line = UIView()
    line.backgroundColor = .separator
    line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
    return line
}
