#!/usr/bin/env python

from __future__ import print_function
import os
import re
import sys
import requests
import termcolor

from signal import SIGINT, signal
from pkg_resources import DistributionNotFound, get_distribution
from argparse import ArgumentParser
from backports.shutil_get_terminal_size import get_terminal_size
from copy import copy
from glob import glob
from natsort import natsorted, ns
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import guess_lexer, guess_lexer_for_filename
from pygments.lexers.special import TextLexer
from PyPDF2 import PdfFileReader, PdfFileWriter
from tempfile import mkstemp
from textwrap import fill
from traceback import print_exception
from warnings import filterwarnings


# require python 2.7+
if sys.version_info < (2, 7):
    sys.exit("You have an old version of python. Install version 2.7 or higher.")
if sys.version_info < (3, 0):
    input = raw_input

# Get version
try:
    d = get_distribution("render50")
except DistributionNotFound:
    __version__ = "UNKNOWN"
else:
    __version__ = d.version


def main():

    # Exit on ctrl-c
    def handler(signum, frame):
        cprint("")
        cancel(1)

    # Register handler
    signal(SIGINT, handler)

    # Parse command-line arguments
    parser = ArgumentParser(description="A command-line tool that "
                                                 "renders source code as a PDF.")
    parser.add_argument("-r", "--recursive", action="store_true", help="recurse into directories")
    parser.add_argument("-i", "--include", action="append", help="pattern to include")
    parser.add_argument("-C", "--no-color", action="store_true", help="disable syntax highlighting")
    parser.add_argument("-o", "--output", help="file to output", required=True)
    parser.add_argument("-s", "--size", help="size of page")
    parser.add_argument("-x", "--exclude", action="append", help="pattern to exclude")
    parser.add_argument("-V", "--version", action="version",
                        version="%(prog)s {}".format(__version__))
    parser.add_argument("-y", "--side-by-side", action="store_true",
                        help="render two inputs side-by-side")
    parser.add_argument("input", help="file or URL to render", nargs="+")
    args = parser.parse_args(sys.argv[1:])

    # Ensure output ends in .pdf
    output = args.output
    if not output.lower().endswith(".pdf"):
        output += ".pdf"

    # Prompt whether to overwrite file as needed
    if os.path.exists(output):
        while True:
            s = input("Overwrite {}? ".format(output))
            if s.lower() in ["y", "yes"]:
                break
            elif s.lower() in ["n", "no"]:
                cancel()

    # Check whether side-by-side
    if args.side_by_side:
        if len(args.input) < 2:
            raise RuntimeError("Too few files to render side by side.")
        elif len(args.input) > 2:
            raise RuntimeError("Too many files to render side by side.")

    # Create parent directory as needed
    dirname = os.path.dirname(os.path.realpath(output))
    if not os.path.isdir(dirname):
        while True:
            s = input("Create {}? ".format(dirname)).strip()
            if s.lower() in ["n", "no"]:
                cancel()
            elif s.lower() in ["y", "yes"]:
                try:
                    os.makedirs(dirname)
                except Exception:
                    e = RuntimeError("Could not create {}.".format(dirname))
                    e.__cause__ = None
                    raise e

    # Determine size
    # https://developer.mozilla.org/en-US/docs/Web/CSS/@page/size
    if not args.size:
        size = "letter landscape"
    elif args.size in ["A5", "A4", "A3", "B5", "B4", "JIS-B5", "JIS-B4", "letter", "legal", "ledger"]:
        size = "{} landscape".format(args.size)
    else:
        size = args.size.strip()

    # Only render two files, side by side
    if args.side_by_side:

        # Halve width
        document = blank(size)
        width = int(document.pages[0].width / 2)
        height = int(document.pages[0].height)
        size = "{}px {}px".format(width, height)

        # Render lefthand input
        fd0, filename0 = mkstemp()
        document = render(args.input[0], color=not args.no_color,
                          fontSize="8pt", margin=".5in .25in .5in .5in", size=size)
        if not document:
            cancel(1)
        write(filename0, [document], False)

        # Render righthand input
        fd1, filename1 = mkstemp()
        document = render(args.input[1], color=not args.no_color,
                          fontSize="8pt", margin=".5in .5in .5in .25in", size=size)
        if not document:
            cancel(1)
        write(filename1, [document], False)

        # Render blank input
        fd2, filename2 = mkstemp()
        write(filename2, [blank(size)], False)

        # Concatenate files side by side
        pdf = PdfFileWriter()
        with open(filename0, "rb") as file0, open(filename1, "rb") as file1, open(filename2, "rb") as file2:

            # Read files
            pdf0 = PdfFileReader(file0)
            pdf1 = PdfFileReader(file1)
            pdf2 = PdfFileReader(file2)

            # Concatenate pages
            for i in range(max(pdf0.getNumPages(), pdf1.getNumPages())):

                # Copy pages
                left = copy(pdf0.getPage(i)) if i < pdf0.getNumPages() else copy(pdf2.getPage(0))
                right = copy(pdf1.getPage(i)) if i < pdf1.getNumPages() else copy(pdf2.getPage(0))

                # Merge pages
                left.mergeTranslatedPage(right, left.mediaBox[2], 0, expand=True)

                # Add pages to output
                pdf.addPage(left)

            # Ouput PDF
            with open(output, "wb") as file:
                pdf.write(file)

        # Remove temporary files
        os.close(fd0), os.remove(filename0)
        os.close(fd1), os.remove(filename1)
        os.close(fd2), os.remove(filename2)

        # Rendered
        cprint("Rendered {}.".format(output), "green")
        sys.exit(0)

    # Check for includes
    includes = []
    if args.include:
        for i in args.include:
            includes.append(re.escape(i).replace("\*", ".*"))

    # Check for excludes
    excludes = []
    if args.exclude:
        for x in args.exclude:
            excludes.append(re.escape(x).replace("\*", ".*"))

    # Check stdin for inputs else command line
    patterns = []
    if len(args.input) == 1 and args.input[0] == "-":
        patterns = sys.stdin.read().splitlines()
    else:
        patterns = args.input

    # Glob patterns lest shell (e.g., Windows) not have done so, ignoring empty patterns
    paths = []
    for pattern in patterns:
        if pattern.startswith(("http", "https")):
            paths += [pattern]
        if pattern:
            paths += natsorted(glob(pattern), alg=ns.IGNORECASE)

    # Candidates to render
    candidates = []
    for path in paths:
        if os.path.isfile(path) or path.startswith(("http", "https")):
            candidates.append(path)
        elif os.path.isdir(path):
            files = []
            for dirpath, dirnames, filenames in os.walk(path):
                for filename in filenames:
                    files.append(os.path.join(dirpath, filename))
            natsorted(files, alg=ns.IGNORECASE)
            candidates += files
        else:
            raise RuntimeError("Could not recognize {}.".format(path))

    # Filter candidates
    queue = []
    for candidate in candidates:

        # Skip implicit exclusions
        if includes and not re.search(r"^" + r"|".join(includes) + "$", candidate):
            continue

        # Skip explicit exclusions
        if excludes and re.search(r"^" + r"|".join(excludes) + "$", candidate):
            continue

        # Queue candidate for rendering
        queue.append(candidate)

    # Render queued files
    documents = []
    for queued in queue:
        document = render(queued, color=not args.no_color, size=size)
        if document:
            documents.append(document)

    # Write rendered files
    write(output, documents)


def blank(size):
    """Render blank page of specified size."""
    return HTML(string="").render(stylesheets=[CSS(string="@page {{ size: {}; }}".format(size))])


def cancel(code=0):
    """Report cancellation, exiting with code"""
    cprint("Rendering cancelled.", "red")
    sys.exit(code)


def cprint(text="", color=None, on_color=None, attrs=None, end="\n"):
    """Colorize text (and wraps to terminal's width)."""

    # Assume 80 in case not running in a terminal
    columns, _ = get_terminal_size()
    if columns == 0:
        columns = 80

    # Print text, flushing output
    termcolor.cprint(fill(text, columns, drop_whitespace=False, replace_whitespace=False),
                     color=color, on_color=on_color, attrs=attrs, end=end)
    sys.stdout.flush()


def excepthook(type, value, tb):
    """Report an exception."""
    excepthook.ignore = False
    if type is RuntimeError and str(value):
        cprint(str(value), "yellow")
    else:
        cprint("Sorry, something's wrong! Let sysadmins@cs50.harvard.edu know!", "yellow")
        print_exception(type, value, tb)
    cancel(1)


sys.excepthook = excepthook


def render(filename, size, color=True, fontSize="10pt", margin=".5in"):
    """Render file with filename as HTML page(s) of specified size."""

    # Rendering
    cprint("Rendering {}...".format(filename), end="")

    # Check if URL
    if filename.startswith(("http", "https")):

        # Get from raw.githubusercontent.com
        matches = re.search(r"^(https?://github.com/[^/]+/[^/]+)/blob/(.+)$", filename)
        if matches:
            filename = "{}/raw/{}".format(matches.group(1), matches.group(2))

        # Get file
        req = requests.get(filename)
        if req.status_code == 200:
            code = req.text
        else:
            cprint("\033[2K", end="\r")
            raise RuntimeError("Could not GET {}.".format(filename))

    # Read file
    else:
        try: 
            file = open(filename, "rb")
            code = file.read().decode("utf-8", "ignore")
            file.close()
        except Exception as e:
            cprint("\033[2K", end="\r")
            if type(e) is FileNotFoundError:
                e = RuntimeError("Could not find {}.".format(filename))
            else:
                e = RuntimeError("Could not read {}.".format(filename))
            e.__cause__ = None
            raise e

    # Check whether binary file
    if "\x00" in code:
        cprint("\033[2K", end="\r")
        cprint("Could not render {} because binary.".format(filename), "yellow")
        return None

    # Highlight code unless file is empty, using inline line numbers to avoid
    # page breaks in tables, https://github.com/Kozea/WeasyPrint/issues/36
    string = ""
    if code.strip() and color:
        try:
            lexer = guess_lexer_for_filename(filename, code)
        except:
            try:
                lexer = guess_lexer(code)
            except:
                lexer = TextLexer()
        string = highlight(code, lexer, HtmlFormatter(linenos="inline", nobackground=True))
    else:
        string = highlight(code, TextLexer(), HtmlFormatter(
            linenos="inline", nobackground=True))

    # Stylize document
    stylesheets = [
        CSS(string="@page {{ border-top: 1px #808080 solid; margin: {}; padding-top: 1em; size: {}; }}".format(margin, size)),
        CSS(string="@page {{ @top-right {{ color: #808080; content: '{}'; padding-bottom: 1em; vertical-align: bottom; }} }}".format(
            filename.replace("'", "\'"))),
        CSS(string="* {{ font-family: monospace; font-size: {}; margin: 0; white-space: pre-wrap; }}".format(fontSize)),
        CSS(string=HtmlFormatter().get_style_defs('.highlight')),
        CSS(string=".highlight { background: initial; }"),
        CSS(string=".lineno { color: #808080; }"),
        CSS(string=".lineno:after { content: '  '; }")]

    # Render document
    document = HTML(string=string).render(stylesheets=stylesheets)

    # Bookmark document
    document.pages[0].bookmarks = [(1, filename, (0, 0))]

    # Rendered
    cprint("\033[2K", end="\r")
    cprint("Rendered {}.".format(filename))
    return document


def write(output, documents, echo=True):
    """
    Write documents to output as PDF.
    https://github.com/Kozea/WeasyPrint/issues/212#issuecomment-52408306
    """
    if documents:
        pages = [page for document in documents for page in document.pages]
        documents[0].copy(pages).write_pdf(output)
        if echo:
            cprint("Rendered {}.".format(output), "green")
    else:
        if echo:
            cprint("Nothing to render.", "red")


# Check for dependencies
# http://weasyprint.readthedocs.io/en/latest/install.html
try:
    # Ignore warnings about outdated Cairo and Pango (on Ubuntu 14.04, at least)
    filterwarnings("ignore", category=UserWarning, module="weasyprint")
    from weasyprint import CSS, HTML
except OSError as e:
    if "pangocairo" in str(e):
        e = RuntimeError("Missing dependency. Install Pango.")
        e.__cause__ = None
        raise e
    elif "cairo" in str(e):
        e = RuntimeError("Missing dependency. Install cairo.")
        e.__cause__ = None
        raise e
    else:
        e = RuntimeError(str(e))
        e.__cause__ = None
        raise e


if __name__ == "__main__":
    main()
