# Copyright 2018-2025 The Khronos Group Inc.
# Copyright (c) 2018-2019 Collabora, Ltd.
#
# SPDX-License-Identifier: Apache-2.0
#
# Author(s):    Rylie Pavlik <rylie.pavlik@collabora.com>
"""Defines HTMLPrinter, a BasePrinter subclass for a single-page HTML results file."""

import html
import re
from collections import namedtuple

from .base_printer import BasePrinter, getColumn
from .shared import (MessageContext, MessageType, generateInclude,
                     getHighlightedRange)

# Bootstrap styles (for constructing CSS class names) associated with MessageType values.
MESSAGE_TYPE_STYLES = {
    MessageType.ERROR: 'danger',
    MessageType.WARNING: 'warning',
    MessageType.NOTE: 'secondary'
}


# HTML Entity for a little emoji-icon associated with MessageType values.
MESSAGE_TYPE_ICONS = {
    MessageType.ERROR: '&#x2297;',  # makeIcon('times-circle'),
    MessageType.WARNING: '&#9888;',  # makeIcon('exclamation-triangle'),
    MessageType.NOTE: '&#x2139;'  # makeIcon('info-circle')
}

LINK_ICON = '&#128279;'  # link icon


class HTMLPrinter(BasePrinter):
    """Implementation of BasePrinter for generating diagnostic reports in HTML format.

    Generates a single file containing neatly-formatted messages.

    The HTML file loads Bootstrap 4 as well as 'prism' syntax highlighting from CDN.
    """

    def __init__(self, filename):
        """Construct by opening the file."""
        self.f = open(filename, 'w', encoding='utf-8')
        self.f.write("""<!doctype html>
        <html lang="en"><head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/themes/prism.min.css" integrity="sha256-N1K43s+8twRa+tzzoF3V8EgssdDiZ6kd9r8Rfgg8kZU=" crossorigin="anonymous" />
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/line-numbers/prism-line-numbers.min.css" integrity="sha256-Afz2ZJtXw+OuaPX10lZHY7fN1+FuTE/KdCs+j7WZTGc=" crossorigin="anonymous" />
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/line-highlight/prism-line-highlight.min.css" integrity="sha256-FFGTaA49ZxFi2oUiWjxtTBqoda+t1Uw8GffYkdt9aco=" crossorigin="anonymous" />
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
        <style>
        pre {
            overflow-x: scroll;
            white-space: nowrap;
        }
        </style>
        <title>check_spec_links results</title>
        </head>
        <body>
        <div class="container">
        <h1><code>check_spec_links.py</code> Scan Results</h1>
        """)
        #
        self.filenameTransformer = re.compile(r'[^\w]+')
        self.fileRange = {}
        self.fileLines = {}
        self.backLink = namedtuple(
            'BackLink', ['lineNum', 'col', 'end_col', 'target', 'tooltip', 'message_type'])
        self.fileBackLinks = {}

        self.nextAnchor = 0
        super().__init__()

    def close(self):
        """Write the tail end of the file and close it."""
        self.f.write("""
        </div>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/prism.min.js" integrity="sha256-jc6y1s/Y+F+78EgCT/lI2lyU7ys+PFYrRSJ6q8/R8+o=" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/keep-markup/prism-keep-markup.min.js" integrity="sha256-mP5i3m+wTxxOYkH+zXnKIG5oJhXLIPQYoiicCV1LpkM=" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-asciidoc.min.js" integrity="sha256-NHPE1p3VBIdXkmfbkf/S0hMA6b4Ar4TAAUlR+Rlogoc=" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/line-numbers/prism-line-numbers.min.js" integrity="sha256-JfF9MVfGdRUxzT4pecjOZq6B+F5EylLQLwcQNg+6+Qk=" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/plugins/line-highlight/prism-line-highlight.min.js" integrity="sha256-DEl9ZQE+lseY13oqm2+mlUr+sVI18LG813P+kzzIm8o=" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.slim.min.js" integrity="sha256-3edrmyuQ0w65f8gfBsqowzjJe2iM6n0nKciPUp8y+7E=" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/esm/popper.min.js" integrity="sha256-T0gPN+ySsI9ixTd/1ciLl2gjdLJLfECKvkQjJn98lOs=" crossorigin="anonymous"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
        <script>
        $(function () {
            $('[data-toggle="tooltip"]').tooltip();
            function autoExpand() {
                var hash = window.location.hash;
                if (!hash) {
                    return;
                }
                $(hash).parents().filter('.collapse').collapse('show');
            }
            window.addEventListener('hashchange', autoExpand);
            $(document).ready(autoExpand);
            $('.accordion').on('shown.bs.collapse', function(e) {
                e.target.parentNode.scrollIntoView();
            })
        })
        </script>
        </body></html>
        """)
        self.f.close()

    ###
    # Output methods: these all write to the HTML file.
    def outputResults(self, checker, broken_links=True,
                      missing_includes=False):
        """Output the full results of a checker run.

        Includes the diagnostics, broken links (if desired),
        missing includes (if desired), and excerpts of all files with diagnostics.
        """
        self.output(checker)
        self.outputBrokenAndMissing(
            checker, broken_links=broken_links, missing_includes=missing_includes)

        self.f.write("""
        <div class="container">
        <h2>Excerpts of referenced files</h2>""")
        for fn in self.fileRange:
            self.outputFileExcerpt(fn)
        self.f.write('</div><!-- .container -->\n')

    def outputChecker(self, checker):
        """Output the contents of a MacroChecker object.

        Starts and ends the accordion populated by outputCheckerFile().
        """
        self.f.write(
            '<div class="container"><h2>Per-File Warnings and Errors</h2>\n')
        self.f.write('<div class="accordion" id="fileAccordion">\n')
        super(HTMLPrinter, self).outputChecker(checker)
        self.f.write("""</div><!-- #fileAccordion -->
        </div><!-- .container -->\n""")

    def outputCheckerFile(self, fileChecker):
        """Output the contents of a MacroCheckerFile object.

        Stashes the lines of the file for later excerpts,
        and outputs any diagnostics in an accordion card.
        """
        # Save lines for later
        self.fileLines[fileChecker.filename] = fileChecker.lines

        if not fileChecker.numDiagnostics():
            return

        self.f.write("""
        <div class="card">
        <div class="card-header" id="{id}-file-heading">
        <div class="row">
        <div class="col">
        <button data-target="#collapse-{id}" class="btn btn-link btn-primary mb-0 collapsed" type="button" data-toggle="collapse" aria-expanded="false" aria-controls="collapse-{id}">
        {relativefn}
        </button>
        </div>
        """.format(id=self.makeIdentifierFromFilename(fileChecker.filename), relativefn=html.escape(self.getRelativeFilename(fileChecker.filename))))
        self.f.write('<div class="col-1">')
        warnings = fileChecker.numMessagesOfType(MessageType.WARNING)
        if warnings > 0:
            self.f.write("""<span class="badge badge-warning" data-toggle="tooltip" title="{num} warnings in this file">
                            {icon}
                            {num}<span class="sr-only"> warnings</span></span>""".format(num=warnings, icon=MESSAGE_TYPE_ICONS[MessageType.WARNING]))
        self.f.write('</div>\n<div class="col-1">')
        errors = fileChecker.numMessagesOfType(MessageType.ERROR)
        if errors > 0:
            self.f.write("""<span class="badge badge-danger" data-toggle="tooltip" title="{num} errors in this file">
                            {icon}
                            {num}<span class="sr-only"> errors</span></span>""".format(num=errors, icon=MESSAGE_TYPE_ICONS[MessageType.ERROR]))
        self.f.write("""
        </div><!-- .col-1 -->
        </div><!-- .row -->
        </div><!-- .card-header -->
        <div id="collapse-{id}" class="collapse" aria-labelledby="{id}-file-heading" data-parent="#fileAccordion">
        <div class="card-body">
        """.format(id=self.makeIdentifierFromFilename(fileChecker.filename)))
        super(HTMLPrinter, self).outputCheckerFile(fileChecker)

        self.f.write("""
        </div><!-- .card-body -->
        </div><!-- .collapse -->
        </div><!-- .card -->
        <!-- ..................................... -->
        """)

    def outputMessage(self, msg):
        """Output a Message."""
        anchor = self.getUniqueAnchor()

        self.recordUsage(msg.context,
                         linkBackTarget=anchor,
                         linkBackTooltip=f'{msg.message_type}: {msg.message[0]} [...]',
                         linkBackType=msg.message_type)

        self.f.write("""
        <div class="card">
        <div class="card-body">
        <h5 class="card-header bg bg-{style}" id="{anchor}">{icon} {t} Line {lineNum}, Column {col} (-{arg})</h5>
        <p class="card-text">
        """.format(
            anchor=anchor,
            icon=MESSAGE_TYPE_ICONS[msg.message_type],
            style=MESSAGE_TYPE_STYLES[msg.message_type],
            t=self.formatBrief(msg.message_type),
            lineNum=msg.context.lineNum,
            col=getColumn(msg.context),
            arg=msg.message_id.enable_arg()))
        self.f.write(self.formatContext(msg.context))
        self.f.write('<br/>')
        for line in msg.message:
            self.f.write(html.escape(line))
            self.f.write('<br />\n')
        self.f.write('</p>\n')
        if msg.see_also:
            self.f.write('<p>See also:</p><ul>\n')
            for see in msg.see_also:
                if isinstance(see, MessageContext):
                    self.f.write(
                        f'<li>{self.formatContext(see)}</li>\n')
                    self.recordUsage(see,
                                     linkBackTarget=anchor,
                                     linkBackType=MessageType.NOTE,
                                     linkBackTooltip=f'see-also associated with {msg.message_type} at {self.formatContextBrief(see)}')
                else:
                    self.f.write(f'<li>{self.formatBrief(see)}</li>\n')
            self.f.write('</ul>')
        if msg.replacement is not None:
            self.f.write(
                '<div class="alert alert-primary">Hover the highlight text to view suggested replacement.</div>')
        if msg.fix is not None:
            self.f.write(
                '<div class="alert alert-info">Note: Auto-fix available.</div>')
        if msg.script_location:
            self.f.write(
                f'<p>Message originated at <code>{msg.script_location}</code></p>')
        self.f.write('<pre class="line-numbers language-asciidoc" data-start="{}"><code>'.format(
            msg.context.lineNum))
        highlightStart, highlightEnd = getHighlightedRange(msg.context)
        self.f.write(html.escape(msg.context.line[:highlightStart]))
        self.f.write(
            f'<span class="border border-{MESSAGE_TYPE_STYLES[msg.message_type]}"')
        if msg.replacement is not None:
            self.f.write(
                f' data-toggle="tooltip" title="{msg.replacement}"')
        self.f.write('>')
        self.f.write(html.escape(
            msg.context.line[highlightStart:highlightEnd]))
        self.f.write('</span>')
        self.f.write(html.escape(msg.context.line[highlightEnd:]))
        self.f.write('</code></pre></div></div>')

    def outputBrokenLinks(self, checker, broken):
        """Output a table of broken links.

        Called by self.outputBrokenAndMissing() if requested.
        """
        self.f.write("""
        <div class="container">
        <h2>Missing Referenced API Includes</h2>
        <p>Items here have been referenced by a linking macro, so these are all broken links in the spec!</p>
        <table class="table table-striped">
        <thead>
        <th scope="col">Add line to include this file</th>
        <th scope="col">or add this macro instead</th>
        <th scope="col">Links to this entity</th></thead>
        """)

        for entity_name, uses in sorted(broken.items()):
            category = checker.findEntity(entity_name).category
            anchor = self.getUniqueAnchor()
            asciidocAnchor = f'[[{entity_name}]]'
            include = generateInclude(dir_traverse='../../generated/',
                                      generated_type='api',
                                      category=category,
                                      entity=entity_name)
            self.f.write(f"""
            <tr id={anchor}>
            <td><code class="text-dark language-asciidoc">{include}</code></td>
            <td><code class="text-dark">{asciidocAnchor}</code></td>
            <td><ul class="list-inline">
            """)
            for context in uses:
                self.f.write(
                    f'<li class="list-inline-item">{self.formatContext(context, MessageType.NOTE)}</li>')
                self.recordUsage(
                    context,
                    linkBackTooltip=f'Link broken in spec: {include} not seen',
                    linkBackTarget=anchor,
                    linkBackType=MessageType.NOTE)
            self.f.write("""</ul></td></tr>""")
        self.f.write("""</table></div>""")

    def outputMissingIncludes(self, checker, missing):
        """Output a table of missing includes.

        Called by self.outputBrokenAndMissing() if requested.
        """
        self.f.write("""
        <div class="container">
        <h2>Missing Unreferenced API Includes</h2>
        <p>These items are expected to be generated in the spec build process, but aren't included.
        However, as they also are not referenced by any linking macros, they aren't broken links - at worst they are undocumented entities,
        at best they are errors in <code>check_spec_links.py</code> logic computing which entities get generated files.</p>
        <table class="table table-striped">
        <thead>
        <th scope="col">Add line to include this file</th>
        <th scope="col">or add this macro instead</th>
        """)

        for entity in sorted(missing):
            fn = checker.findEntity(entity).filename
            anchor = f'[[{entity}]]'
            self.f.write(f"""
            <tr>
            <td><code class="text-dark">{fn}</code></td>
            <td><code class="text-dark">{anchor}</code></td>
            """)
        self.f.write("""</table></div>""")

    def outputFileExcerpt(self, filename):
        """Output a card containing an excerpt of a file, sufficient to show locations of all diagnostics plus some context.

        Called by self.outputResults().
        """
        self.f.write("""<div class="card">
            <div class="card-header" id="heading-{id}"><h5 class="mb-0">
            <button class="btn btn-link" type="button">
            {fn}
            </button></h5></div><!-- #heading-{id} -->
            <div class="card-body">
            """.format(id=self.makeIdentifierFromFilename(filename), fn=self.getRelativeFilename(filename)))
        lines = self.fileLines[filename]
        r = self.fileRange[filename]
        self.f.write("""<pre class="line-numbers language-asciidoc line-highlight" id="excerpt-{id}" data-start="{start}"><code>""".format(
            id=self.makeIdentifierFromFilename(filename),
            start=r.start))
        for lineNum, line in enumerate(
                lines[(r.start - 1):(r.stop - 1)], r.start):
            # self.f.write(line)
            lineLinks = [x for x in self.fileBackLinks[filename]
                         if x.lineNum == lineNum]
            for col, char in enumerate(line):
                colLinks = (x for x in lineLinks if x.col == col)
                for link in colLinks:
                    # TODO right now the syntax highlighting is interfering with the link! so the link-generation is commented out,
                    # only generating the emoji icon.

                    # self.f.write('<a href="#{target}" title="{title}" data-toggle="tooltip" data-container="body">{icon}'.format(
                    # target=link.target, title=html.escape(link.tooltip),
                    # icon=MESSAGE_TYPE_ICONS[link.message_type]))
                    self.f.write(MESSAGE_TYPE_ICONS[link.message_type])
                    self.f.write('<span class="sr-only">Cross reference: {t} {title}</span>'.format(
                        title=html.escape(link.tooltip, False), t=link.message_type))

                    # self.f.write('</a>')

                # Write the actual character
                self.f.write(html.escape(char))
            self.f.write('\n')

        self.f.write('</code></pre>')
        self.f.write('</div><!-- .card-body -->\n')
        self.f.write('</div><!-- .card -->\n')

    def outputFallback(self, obj):
        """Output some text in a general way."""
        self.f.write(obj)

    ###
    # Format method: return a string.
    def formatContext(self, context, message_type=None):
        """Format a message context in a verbose way."""
        if message_type is None:
            icon = LINK_ICON
        else:
            icon = MESSAGE_TYPE_ICONS[message_type]
        return 'In context: <a href="{href}">{icon}{relative}:{lineNum}:{col}</a>'.format(
            href=self.getAnchorLinkForContext(context),
            icon=icon,
            # id=self.makeIdentifierFromFilename(context.filename),
            relative=self.getRelativeFilename(context.filename),
            lineNum=context.lineNum,
            col=getColumn(context))

    ###
    # Internal methods: not mandated by parent class.
    def recordUsage(self, context, linkBackTooltip=None,
                    linkBackTarget=None, linkBackType=MessageType.NOTE):
        """Internally record a 'usage' of something.

        Increases the range of lines that are included in the excerpts,
        and records back-links if appropriate.
        """
        BEFORE_CONTEXT = 6
        AFTER_CONTEXT = 3
        # Clamp because we need accurate start line number to make line number
        # display right
        start = max(1, context.lineNum - BEFORE_CONTEXT)
        stop = context.lineNum + AFTER_CONTEXT + 1
        if context.filename not in self.fileRange:
            self.fileRange[context.filename] = range(start, stop)
            self.fileBackLinks[context.filename] = []
        else:
            oldRange = self.fileRange[context.filename]
            self.fileRange[context.filename] = range(
                min(start, oldRange.start), max(stop, oldRange.stop))

        if linkBackTarget is not None:
            start_col, end_col = getHighlightedRange(context)
            self.fileBackLinks[context.filename].append(self.backLink(
                lineNum=context.lineNum, col=start_col, end_col=end_col,
                target=linkBackTarget, tooltip=linkBackTooltip,
                message_type=linkBackType))

    def makeIdentifierFromFilename(self, fn):
        """Compute an acceptable HTML anchor name from a filename."""
        return self.filenameTransformer.sub('_', self.getRelativeFilename(fn))

    def getAnchorLinkForContext(self, context):
        """Compute the anchor link to the excerpt for a MessageContext."""
        return '#excerpt-{}.{}'.format(
            self.makeIdentifierFromFilename(context.filename), context.lineNum)

    def getUniqueAnchor(self):
        """Create and return a new unique string usable as a link anchor."""
        anchor = f'anchor-{self.nextAnchor}'
        self.nextAnchor += 1
        return anchor
