# Copyright (c) 2020-2023, Manfred Moitzi
# License: MIT License
# type: ignore
from __future__ import annotations
from typing import Optional
import logging
import os
import platform
import shutil
import subprocess
import tempfile
import time
from contextlib import contextmanager
from pathlib import Path

import ezdxf
from ezdxf.document import Drawing
from ezdxf.lldxf.validator import (
    is_dxf_file,
    dxf_info,
    is_binary_dxf_file,
    dwg_version,
)

logger = logging.getLogger("ezdxf")
win_exec_path = ezdxf.options.get("odafc-addon", "win_exec_path").strip('"')
unix_exec_path = ezdxf.options.get("odafc-addon", "unix_exec_path").strip('"')


class ODAFCError(IOError):
    pass


class UnknownODAFCError(ODAFCError):
    pass


class ODAFCNotInstalledError(ODAFCError):
    pass


class UnsupportedFileFormat(ODAFCError):
    pass


class UnsupportedPlatform(ODAFCError):
    pass


class UnsupportedVersion(ODAFCError):
    pass


VERSION_MAP = {
    "R12": "ACAD12",
    "R13": "ACAD13",
    "R14": "ACAD14",
    "R2000": "ACAD2000",
    "R2004": "ACAD2004",
    "R2007": "ACAD2007",
    "R2010": "ACAD2010",
    "R2013": "ACAD2013",
    "R2018": "ACAD2018",
    "AC1004": "ACAD9",
    "AC1006": "ACAD10",
    "AC1009": "ACAD12",
    "AC1012": "ACAD13",
    "AC1014": "ACAD14",
    "AC1015": "ACAD2000",
    "AC1018": "ACAD2004",
    "AC1021": "ACAD2007",
    "AC1024": "ACAD2010",
    "AC1027": "ACAD2013",
    "AC1032": "ACAD2018",
}

VALID_VERSIONS = {
    "ACAD9",
    "ACAD10",
    "ACAD12",
    "ACAD13",
    "ACAD14",
    "ACAD2000",
    "ACAD2004",
    "ACAD2007",
    "ACAD2010",
    "ACAD2013",
    "ACAD2018",
}

WINDOWS = "Windows"
LINUX = "Linux"
DARWIN = "Darwin"


def is_installed() -> bool:
    """Returns ``True`` if the ODAFileConverter is installed."""
    if platform.system() in (LINUX, DARWIN):
        if unix_exec_path and Path(unix_exec_path).is_file():
            return True
        return shutil.which("ODAFileConverter") is not None
    # Windows:
    return os.path.exists(win_exec_path)


def map_version(version: str) -> str:
    return VERSION_MAP.get(version.upper(), version.upper())


def readfile(
    filename: str | os.PathLike, version: Optional[str] = None, *, audit: bool = False
) -> Optional[Drawing]:
    """Uses an installed `ODA File Converter`_ to convert a DWG/DXB/DXF file
    into a temporary DXF file and load this file by `ezdxf`.

    Args:
        filename: file to load by ODA File Converter
        version: load file as specific DXF version, by default the same version
            as the source file or if not detectable the latest by `ezdxf`
            supported version.
        audit: audit source file before loading

    Raises:
        FileNotFoundError: source file not found
        odafc.UnknownODAFCError: conversion failed for unknown reasons
        odafc.UnsupportedVersion: invalid DWG version specified
        odafc.UnsupportedFileFormat: unsupported file extension
        odafc.ODAFCNotInstalledError: ODA File Converter not installed

    """
    infile = Path(filename).absolute()
    if not infile.is_file():
        raise FileNotFoundError(f"No such file: '{infile}'")
    if isinstance(version, str):
        version = map_version(version)
    else:
        version = _detect_version(filename)

    with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
        args = _odafc_arguments(
            infile.name,
            str(infile.parent),
            tmp_dir,
            output_format="DXF",
            version=version,
            audit=audit,
        )
        _execute_odafc(args)
        out_file = Path(tmp_dir) / infile.with_suffix(".dxf").name
        if out_file.exists():
            doc = ezdxf.readfile(str(out_file))
            doc.filename = infile.with_suffix(".dxf")
            return doc
    raise UnknownODAFCError("Failed to convert file: Unknown Error")


def export_dwg(
    doc: Drawing,
    filename: str | os.PathLike,
    version: Optional[str] = None,
    *,
    audit: bool = False,
    replace: bool = False,
) -> None:
    """Uses an installed `ODA File Converter`_ to export the DXF document `doc`
    as a DWG file.

    A temporary DXF file will be created and converted to DWG by the
    ODA File Converter. If `version` is not specified the DXF version of the
    source document is used.

    Args:
        doc: `ezdxf` DXF document as :class:`~ezdxf.drawing.Drawing` object
        filename: output DWG filename, the extension will be set to ".dwg"
        version: DWG version to export, by default the same version as
            the source document.
        audit: audit source file by ODA File Converter at exporting
        replace: replace existing DWG file if ``True``

    Raises:
        FileExistsError: target file already exists, and argument `replace` is
            ``False``
        FileNotFoundError: parent directory of target file does not exist
        odafc.UnknownODAFCError: exporting DWG failed for unknown reasons
        odafc.ODAFCNotInstalledError: ODA File Converter not installed

    """
    if version is None:
        version = doc.dxfversion
    export_version = VERSION_MAP[version]
    dwg_file = Path(filename).absolute()
    out_folder = Path(dwg_file.parent)
    if dwg_file.exists():
        if replace:
            dwg_file.unlink()
        else:
            raise FileExistsError(f"File already exists: {dwg_file}")
    if out_folder.exists():
        with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
            dxf_file = Path(tmp_dir) / dwg_file.with_suffix(".dxf").name

            # Save DXF document
            old_filename = doc.filename
            doc.saveas(dxf_file)
            doc.filename = old_filename

            arguments = _odafc_arguments(
                dxf_file.name,
                tmp_dir,
                str(out_folder),
                output_format="DWG",
                version=export_version,
                audit=audit,
            )
            _execute_odafc(arguments)
    else:
        raise FileNotFoundError(f"No such file or directory: '{str(out_folder)}'")


def convert(
    source: str | os.PathLike,
    dest: str | os.PathLike = "",
    *,
    version="R2018",
    audit=True,
    replace=False,
):
    """Convert `source` file to `dest` file.

    The file extension defines the target format
    e.g. :code:`convert("test.dxf", "Test.dwg")` converts the source file to a
    DWG file.
    If `dest` is an empty string the conversion depends on the source file
    format and is DXF to DWG or DWG to DXF.
    To convert DXF to DXF an explicit destination filename is required:
    :code:`convert("r12.dxf", "r2013.dxf", version="R2013")`

    Args:
        source: source file
        dest: destination file, an empty string uses the source filename with
            the extension of the target format e.g. "test.dxf" -> "test.dwg"
        version: output DXF/DWG version e.g. "ACAD2018", "R2018", "AC1032"
        audit: audit files
        replace: replace existing destination file

    Raises:
        FileNotFoundError: source file or destination folder does not exist
        FileExistsError: destination file already exists and argument `replace`
            is ``False``
        odafc.UnsupportedVersion: invalid DXF version specified
        odafc.UnsupportedFileFormat: unsupported file extension
        odafc.UnknownODAFCError: conversion failed for unknown reasons
        odafc.ODAFCNotInstalledError: ODA File Converter not installed

    """
    version = map_version(version)
    if version not in VALID_VERSIONS:
        raise UnsupportedVersion(f"Invalid version: '{version}'")
    src_path = Path(source).expanduser().absolute()
    if not src_path.exists():
        raise FileNotFoundError(f"Source file not found: '{source}'")
    if dest:
        dest_path = Path(dest)
    else:
        ext = src_path.suffix.lower()
        if ext == ".dwg":
            dest_path = src_path.with_suffix(".dxf")
        elif ext == ".dxf":
            dest_path = src_path.with_suffix(".dwg")
        else:
            raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")
    if dest_path.exists() and not replace:
        raise FileExistsError(f"Target file already exists: '{dest_path}'")
    parent_dir = dest_path.parent
    if not parent_dir.exists() or not parent_dir.is_dir():
        # Cannot copy result to destination folder!
        raise FileNotFoundError(f"Destination folder does not exist: '{parent_dir}'")
    ext = dest_path.suffix
    fmt = ext.upper()[1:]
    if fmt not in ("DXF", "DWG"):
        raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")

    with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir:
        arguments = _odafc_arguments(
            src_path.name,
            in_folder=str(src_path.parent),
            out_folder=str(tmp_dir),
            output_format=fmt,
            version=version,
            audit=audit,
        )
        _execute_odafc(arguments)
        result = list(Path(tmp_dir).iterdir())
        if result:
            try:
                shutil.move(result[0], dest_path)
            except IOError:
                shutil.copy(result[0], dest_path)
        else:
            UnknownODAFCError(f"Unknown error: no {fmt} file was created")


def _detect_version(path: str) -> str:
    """Returns the DXF/DWG version of file `path` as ODAFC compatible version
    string.

    Raises:
        odafc.UnsupportedVersion: unknown or unsupported DWG version
        odafc.UnsupportedFileFormat; unsupported file extension

    """
    version = "ACAD2018"
    ext = os.path.splitext(path)[1].lower()
    if ext == ".dxf":
        if is_binary_dxf_file(path):
            pass
        elif is_dxf_file(path):
            with open(path, "rt") as fp:
                info = dxf_info(fp)
                version = VERSION_MAP[info.version]
    elif ext == ".dwg":
        version = dwg_version(path)
        if version is None:
            raise UnsupportedVersion("Unknown or unsupported DWG version.")
    else:
        raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'")

    return map_version(version)


def _odafc_arguments(
    filename: str,
    in_folder: str,
    out_folder: str,
    output_format: str = "DXF",
    version: str = "ACAD2013",
    audit: bool = False,
) -> list[str]:
    """ODA File Converter command line format:

    ODAFileConverter "Input Folder" "Output Folder" version type recurse audit [filter]

        - version: output version: "ACAD9" - "ACAD2018"
        - type: output file type: "DWG", "DXF", "DXB"
        - recurse: recurse Input Folder: "0" or "1"
        - audit: audit each file: "0" or "1"
        - optional Input files filter: default "*.DWG,*.DXF"

    """
    recurse = "0"
    audit_str = "1" if audit else "0"
    return [
        in_folder,
        out_folder,
        version,
        output_format,
        recurse,
        audit_str,
        filename,
    ]


def _get_odafc_path(system: str) -> str:
    """Get ODAFC application path.

    Raises:
        odafc.ODAFCNotInstalledError: ODA File Converter not installed

    """
    # on Linux and Darwin check if UNIX_EXEC_PATH is set and exist and
    # return this path as exec path.
    # This may help if the "which" command can not find the "ODAFileConverter"
    # command and also adds support for AppImages provided by ODA.
    if system != WINDOWS and unix_exec_path:
        if Path(unix_exec_path).is_file():
            return unix_exec_path
        else:
            logger.warning(
                f"command '{unix_exec_path}' not found, using 'ODAFileConverter'"
            )

    path = shutil.which("ODAFileConverter")
    if not path and system == WINDOWS:
        path = win_exec_path
        if not Path(path).is_file():
            path = None

    if not path:
        raise ODAFCNotInstalledError(
            f"Could not find ODAFileConverter in the path. "
            f"Install application from https://www.opendesign.com/guestfiles/oda_file_converter"
        )
    return path


@contextmanager
def _linux_dummy_display():
    """See xvbfwrapper library for a more feature complete xvfb interface."""
    if shutil.which("Xvfb"):
        display = ":123"  # arbitrary choice
        proc = subprocess.Popen(
            ["Xvfb", display, "-screen", "0", "800x600x24"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        time.sleep(0.1)
        yield display
        try:
            proc.terminate()
            proc.wait()
        except OSError:
            pass
    else:
        logger.warning(f"Install xvfb to prevent the ODAFileConverter GUI from opening")
        yield os.environ["DISPLAY"]


def _run_with_no_gui(
    system: str, command: str, arguments: list[str]
) -> subprocess.Popen:
    """Execute ODAFC application without launching the GUI.

    Args:
        system: "Linux", "Windows" or "Darwin"
        command: application to execute
        arguments: ODAFC argument list

    Raises:
        odafc.UnsupportedPlatform: for unsupported platforms
        odafc.ODAFCNotInstalledError: ODA File Converter not installed

    """
    if system == LINUX:
        with _linux_dummy_display() as display:
            env = os.environ.copy()
            env["DISPLAY"] = display
            proc = subprocess.run([command] + arguments, text=True, capture_output=True, env=env)

    elif system == DARWIN:
        # TODO: unknown how to prevent the GUI from appearing on macOS
        proc = subprocess.run([command] + arguments, text=True, capture_output=True)

    elif system == WINDOWS:
        # New code from George-Jiang to solve the GUI pop-up problem
        startupinfo = subprocess.STARTUPINFO()
        startupinfo.dwFlags = (
            subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW
        )
        startupinfo.wShowWindow = subprocess.SW_HIDE
        proc = subprocess.Popen(
            [command] + arguments,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            startupinfo=startupinfo,
        )
        proc.wait()
    else:
        # The OpenDesign Alliance only provides executables for Linux, macOS
        # and Windows:
        raise UnsupportedPlatform(f"Unsupported platform: {system}")
    return proc


def _odafc_failed(system: str, returncode: int, stderr: str) -> bool:
    # changed v0.18.1, see https://github.com/mozman/ezdxf/issues/707
    stderr = stderr.strip()
    if system == LINUX:
        # ODAFileConverter *always* crashes on Linux even if the output was successful
        return stderr != "" and stderr != "Quit (core dumped)"
    elif returncode != 0:
        return True
    else:
        return stderr != ""


def _execute_odafc(arguments: list[str]) -> Optional[bytes]:
    """Execute ODAFC application.

    Args:
        arguments: ODAFC argument list

    Raises:
        odafc.ODAFCNotInstalledError: ODA File Converter not installed
        odafc.UnknownODAFCError: execution failed for unknown reasons
        odafc.UnsupportedPlatform: for unsupported platforms

    """
    logger.debug(f"Running ODAFileConverter with arguments: {arguments}")
    system = platform.system()
    oda_fc = _get_odafc_path(system)
    proc = _run_with_no_gui(system, oda_fc, arguments)
    returncode = proc.returncode
    if system == WINDOWS:
        stdout = proc.stdout.read().decode("utf-8")
        stderr = proc.stderr.read().decode("utf-8")
    else:
        stdout = proc.stdout
        stderr = proc.stderr

    if _odafc_failed(system, returncode, stderr):
        msg = (
            f"ODA File Converter failed: return code = {returncode}.\n"
            f"stdout: {stdout}\nstderr: {stderr}"
        )
        logger.debug(msg)
        raise UnknownODAFCError(msg)
    return stdout
