from __future__ import annotations

import io
import shutil
import subprocess
import sys
from binascii import hexlify
from functools import cache
from importlib import resources
from math import ceil
from textwrap import TextWrapper, indent

from PIL import Image

from .data import BarcodeType, barcode_types

__all__ = ["generate_barcode", "TreepoemError", "BarcodeType", "barcode_types"]


# Inline the BWIPP code rather than using the run operator to execute
# it because the EpsImagePlugin runs Ghostscript with the SAFER flag,
# which disables file operations in the PS code.
@cache
def load_bwipp() -> str:
    with (
        resources.files("treepoem")
        .joinpath("postscriptbarcode/barcode.ps")
        .open() as fp
    ):
        return fp.read()


# Error handling from:
# https://github.com/bwipp/postscriptbarcode/wiki/Developing-a-Frontend-to-BWIPP#use-bwipps-error-reporting  # noqa: E501
BBOX_TEMPLATE = """\
%!PS

{bwipp}
/Helvetica findfont 10 scalefont setfont
{{
  0 0 moveto
  {data_options_encoder}
  /uk.co.terryburton.bwipp findresource exec
  showpage
}} stopped {{  % "catch" all exceptions
  $error /errorname get dup length string cvs 0 6 getinterval (bwipp.) ne {{
    stop  % Rethrow non-BWIPP exceptions
  }} if
  % Handle BWIPP exceptions, e.g. emit formatted error to stderr
  (%stderr) (w) file
  dup (\nBWIPP ERROR: ) writestring
  dup $error /errorname get dup length string cvs writestring
  dup ( ) writestring
  dup $error /errorinfo get dup length string cvs writestring
  dup (\n) writestring
  dup flushfile
}} if
"""


EPS_TEMPLATE = """\
%!PS-Adobe-3.0 EPSF-3.0
%%BoundingBox: 0 0 {ceilwidth} {ceilheight}
%%HiResBoundingBox: 0 0 {width} {height}
%%Pages: 1
%%LanguageLevel: 2
%%EndComments
%%BeginProlog
{bwipp}
%%EndProlog
%%Page: 1 1
/Helvetica findfont 10 scalefont setfont
{translate_x} {translate_y} moveto
{data_options_encoder} /uk.co.terryburton.bwipp findresource exec
showpage
%%Trailer
%%EOF
"""


class TreepoemError(RuntimeError):
    pass


@cache
def _ghostscript_binary() -> str:
    if sys.platform.startswith("win"):
        options = ("gswin32c", "gswin64c", "gs")
    else:
        options = ("gs",)
    for name in options:
        if shutil.which(name) is not None:
            return name
    raise TreepoemError("Cannot determine path to ghostscript, is it installed?")


# Argument passing per:
# https://github.com/bwipp/postscriptbarcode/wiki/Developing-a-Frontend-to-BWIPP#safe-argument-passing  # noqa: E501
def _hexify(data: str | bytes) -> str:
    if isinstance(data, str):
        data = data.encode("utf-8")
    return TextWrapper(subsequent_indent=" ", width=72).fill(
        f"<{hexlify(data).decode('ascii')}>"
    )


def _format_options(options: dict[str, str | bool]) -> str:
    items = []
    for name, value in options.items():
        if isinstance(value, bool):
            if value:
                items.append(name)
        else:
            items.append(f"{name}={value}")
    return " ".join(items)


def _format_data_options_encoder(
    data: str | bytes,
    options: dict[str, str | bool],
    barcode_type: str,
) -> str:
    return (
        f"{_hexify(data)}\n{_hexify(_format_options(options))}\n"
        f"{_hexify(barcode_type)} cvn"
    )


def generate_barcode(
    barcode_type: str,
    data: str | bytes,
    options: dict[str, str | bool] | None = None,
    *,
    scale: int = 2,
) -> Image.Image:
    if barcode_type not in barcode_types:
        raise NotImplementedError(f"unsupported barcode type {barcode_type!r}")
    if options is None:
        options = {}
    if scale < 1:
        raise ValueError("scale must be at least 1")

    # https://github.com/bwipp/postscriptbarcode/wiki/Developing-a-Frontend-to-BWIPP#generating-cropped-images-via-eps
    bwipp = load_bwipp()
    data_options_encoder = _format_data_options_encoder(data, options, barcode_type)
    bbox_code = BBOX_TEMPLATE.format(
        bwipp=bwipp,
        data_options_encoder=indent(data_options_encoder, "  "),
    )
    page_offset = 3000

    # Prevent GhostScript popup windows on Windows
    creationflags = 0
    if hasattr(subprocess, "CREATE_NO_WINDOW"):  # pragma: no cover
        creationflags |= subprocess.CREATE_NO_WINDOW

    gs_process = subprocess.run(
        [
            _ghostscript_binary(),
            "-dSAFER",
            "-dQUIET",
            "-dNOPAUSE",
            "-dBATCH",
            "-sDEVICE=bbox",
            "-c",
            f"<</PageOffset [{page_offset} {page_offset}]>> setpagedevice",
            "-f",
            "-",
        ],
        text=True,
        capture_output=True,
        check=True,
        input=bbox_code,
        creationflags=creationflags,
    )
    err_output = gs_process.stderr.strip()
    # Unfortunately the error-handling in the postscript means that
    # returncode is 0 even if there was an error, but this gives
    # better error messages.
    if gs_process.returncode != 0 or "BWIPP ERROR:" in err_output:
        if err_output.startswith("BWIPP ERROR: "):
            err_output = err_output.replace("BWIPP ERROR: ", "", 1)
        raise TreepoemError(err_output)
    hiresbbox_line = err_output.split("\n", 2)[1]
    assert hiresbbox_line.startswith("%%HiResBoundingBox: ")
    numbers = hiresbbox_line[len("%%HiResBoundingBox: ") :].split(" ")
    assert len(numbers) == 4
    bbx1, bby1, bbx2, bby2 = (float(n) for n in numbers)

    width = bbx2 - bbx1
    height = bby2 - bby1
    translate_x = page_offset - bbx1
    translate_y = page_offset - bby1

    full_code = EPS_TEMPLATE.format(
        ceilwidth=int(ceil(width)),
        ceilheight=int(ceil(height)),
        width=width,
        height=height,
        bwipp=bwipp,
        translate_x=translate_x,
        translate_y=translate_y,
        data_options_encoder=data_options_encoder,
    )
    gs_process2 = subprocess.run(
        [
            _ghostscript_binary(),
            "-dSAFER",
            "-dQUIET",
            "-dNOPAUSE",
            "-dBATCH",
            "-sDEVICE=png16m",
            f"-dDEVICEWIDTHPOINTS={width}",
            f"-dDEVICEHEIGHTPOINTS={height}",
            f"-r{72 * scale}",
            "-dTextAlphaBits=4",
            "-dGraphicsAlphaBits=1",
            "-sOutputFile=-",
            "-",
        ],
        capture_output=True,
        check=True,
        input=full_code.encode(),
        creationflags=creationflags,
    )
    return Image.open(io.BytesIO(gs_process2.stdout))
