import io
import logging
import os
import shutil
import subprocess
import tempfile
import zipfile
from pathlib import Path


class ConversionError(Exception):
    def __init__(self, message, status=400):
        super().__init__(message)
        self.status = status


OFFICE_FORMATS = {
    'doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx',
    'odt', 'odp', 'ods', 'rtf', 'html', 'htm',
}
IMAGE_FORMATS = {'jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'tif', 'tiff'}


def _validate(source, content):
    signatures = {
        'pdf': (b'%PDF-',),
        'zip': (b'PK\x03\x04', b'PK\x05\x06', b'PK\x07\x08'),
        'jpg': (b'\xff\xd8\xff',),
        'jpeg': (b'\xff\xd8\xff',),
        'png': (b'\x89PNG\r\n\x1a\n',),
        'gif': (b'GIF87a', b'GIF89a'),
        'bmp': (b'BM',),
    }
    if not content:
        raise ConversionError('The uploaded file is empty.')
    if source in signatures and not content.startswith(signatures[source]):
        raise ConversionError(f'The uploaded file is not a valid {source.upper()} file.')
    if source in {'docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods'}:
        try:
            with zipfile.ZipFile(io.BytesIO(content)) as archive:
                names = archive.namelist()
                if any(name.startswith('/') or '..' in Path(name).parts for name in names):
                    raise ConversionError('The uploaded document contains unsafe paths.')
                if '[Content_Types].xml' not in names:
                    raise ConversionError(f'The uploaded file is not a valid {source.upper()} file.')
        except zipfile.BadZipFile as exc:
            raise ConversionError(f'The uploaded file is not a valid {source.upper()} file.') from exc


def _tool(name):
    candidates = []
    configured = os.environ.get('POPPLER_BIN_PATH')
    if configured:
        candidates.append(Path(configured) / f'{name}.exe')
    path = shutil.which(name)
    if path:
        candidates.append(Path(path))
    winget_root = Path(os.environ.get('LOCALAPPDATA', '')) / 'Microsoft' / 'WinGet' / 'Packages'
    if winget_root.is_dir():
        candidates.extend(winget_root.glob(f'*Poppler*/*/Library/bin/{name}.exe'))
    for candidate in candidates:
        if candidate.is_file():
            return str(candidate)
    raise ConversionError('This conversion is temporarily unavailable.', 503)


def _run(command, timeout, cwd=None, environment=None):
    process = None
    try:
        process = subprocess.Popen(
            command,
            capture_output=True,
            shell=False,
            cwd=cwd,
            env=environment,
        )
        stdout, stderr = process.communicate(timeout=timeout)
    except subprocess.TimeoutExpired as exc:
        if process:
            process.kill()
            process.communicate()
        raise ConversionError('Conversion timed out.', 504) from exc
    except OSError as exc:
        logging.exception('Conversion process could not start')
        raise ConversionError('This conversion is temporarily unavailable.', 503) from exc
    if process.returncode:
        logging.error('Conversion failed: %s', stderr[-2000:].decode(errors='replace'))
        raise ConversionError('The file could not be converted.', 422)


def _libreoffice(source, target, content, executable, timeout):
    with tempfile.TemporaryDirectory(prefix='convert-hau-') as folder:
        root = Path(folder)
        input_path = root / f'input.{source}'
        output_dir = root / 'output'
        profile = root / 'profile'
        output_dir.mkdir()
        profile.mkdir()
        input_path.write_bytes(content)
        executable_path = Path(executable).resolve()
        program_dir = executable_path.parent
        environment = None
        if executable_path.name.lower() in {'soffice.exe', 'soffice.com'}:
            environment = dict(__import__('os').environ)
            environment['PATH'] = f'{program_dir};{environment.get("PATH", "")}'
            environment['URE_BOOTSTRAP'] = f'vnd.sun.star.pathname:{program_dir / "fundamental.ini"}'
        command = [
            executable,
            f'-env:UserInstallation={profile.as_uri()}',
            '--headless',
            '--nologo',
            '--nodefault',
            '--nofirststartwizard',
            '--norestore',
            '--nolockcheck',
            '--convert-to', target,
            '--outdir', str(output_dir),
            str(input_path),
        ]
        _run(command, timeout, cwd=program_dir, environment=environment)
        output_path = output_dir / f'input.{target}'
        if not output_path.is_file() or output_path.stat().st_size == 0:
            raise ConversionError('The conversion engine produced no output.', 422)
        return output_path.read_bytes()


def _image_pdf(source, content, timeout):
    try:
        from PIL import Image
    except ImportError as exc:
        raise ConversionError('This conversion is temporarily unavailable.', 503) from exc
    try:
        image = Image.open(io.BytesIO(content)).convert('RGB')
        output = io.BytesIO()
        image.save(output, format='PDF', resolution=150.0)
        return output.getvalue()
    except Exception as exc:
        raise ConversionError('The image could not be converted.', 422) from exc


def _image_image(source, target, content):
    try:
        from PIL import Image
        image = Image.open(io.BytesIO(content))
        output = io.BytesIO()
        format_name = 'JPEG' if target in {'jpg', 'jpeg'} else target.upper()
        if format_name == 'JPEG' and image.mode in {'RGBA', 'LA', 'P'}:
            image = image.convert('RGB')
        image.save(output, format=format_name)
        return output.getvalue()
    except ImportError as exc:
        raise ConversionError('This conversion is temporarily unavailable.', 503) from exc
    except Exception as exc:
        raise ConversionError('The image could not be converted.', 422) from exc


def _pdf_images(target, content, timeout):
    tool = _tool('pdftocairo')
    with tempfile.TemporaryDirectory(prefix='convert-hau-') as folder:
        root = Path(folder)
        source_path = root / 'input.pdf'
        prefix = root / 'page'
        source_path.write_bytes(content)
        _run([tool, '-singlefile', '-r', '150', f'-{target}', str(source_path), str(prefix)], timeout)
        output_path = prefix.with_suffix(f'.{target}')
        if not output_path.is_file():
            raise ConversionError('The conversion engine produced no output.', 422)
        return output_path.read_bytes()


def _text(source, target, content):
    if target == 'txt':
        if source in {'txt', 'md', 'markdown'}:
            return content
        raise ConversionError('Text extraction is not available for this file type.', 422)
    if source in {'txt', 'md', 'markdown'} and target in {'txt', 'md', 'markdown'}:
        return content
    raise ConversionError('This text conversion is not supported.', 422)


def _pdf_text(content, timeout):
    tool = _tool('pdftotext')
    with tempfile.TemporaryDirectory(prefix='convert-hau-') as folder:
        root = Path(folder)
        source = root / 'input.pdf'
        output = root / 'output.txt'
        source.write_bytes(content)
        _run([tool, str(source), str(output)], timeout)
        return output.read_bytes()


def _pdf_docx(content, timeout):
    try:
        from pdf2docx import Converter
        with tempfile.TemporaryDirectory(prefix='convert-hau-pdf2docx-') as folder:
            root = Path(folder)
            source = root / 'input.pdf'
            output = root / 'output.docx'
            source.write_bytes(content)
            converter = Converter(str(source))
            try:
                converter.convert(str(output), start=0, end=None)
            finally:
                converter.close()
            if output.is_file() and output.stat().st_size:
                return output.read_bytes()
    except Exception:
        logging.exception('Editable PDF-to-DOCX reconstruction failed; using visual fallback')

    # A visual DOCX is preferable to silently returning text when reconstruction
    # cannot represent a complex or scanned PDF.
    tool = _tool('pdftocairo')
    namespaces = (
        'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" '
        'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
        'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" '
        'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
        'xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"'
    )
    with tempfile.TemporaryDirectory(prefix='convert-hau-') as folder:
        root = Path(folder)
        source = root / 'input.pdf'
        prefix = root / 'page'
        source.write_bytes(content)
        _run([tool, '-png', '-r', '150', str(source), str(prefix)], timeout)
        pages = sorted(root.glob('page-*.png'), key=lambda path: int(path.stem.rsplit('-', 1)[1]))
        if not pages:
            raise ConversionError('The PDF renderer produced no pages.', 422)

        document_parts = []
        relationships = []
        media = {}
        for index, page in enumerate(pages, 1):
            image_name = f'image{index}.png'
            relationship_id = f'rId{index}'
            image = page.read_bytes()
            media[image_name] = image
            from PIL import Image
            with Image.open(io.BytesIO(image)) as opened:
                width = opened.width * 9525
                height = opened.height * 9525
            document_parts.append(f'''<w:p><w:r><w:drawing><wp:inline>
<wp:extent cx="{width}" cy="{height}"/><wp:docPr id="{index}" name="PDF page {index}"/>
<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic><pic:nvPicPr><pic:cNvPr id="{index}" name="{image_name}"/><pic:cNvPicPr/></pic:nvPicPr>
<pic:blipFill><a:blip r:embed="{relationship_id}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>
<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{width}" cy="{height}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>
</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>''')
            if index < len(pages):
                document_parts.append('<w:p><w:r><w:br w:type="page"/></w:r></w:p>')
            relationships.append(
                f'<Relationship Id="{relationship_id}" '
                'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
                f'Target="media/{image_name}"/>'
            )

        output = io.BytesIO()
        with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as archive:
            archive.writestr('[Content_Types].xml', '''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>''')
            archive.writestr('_rels/.rels', '''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>''')
            archive.writestr('word/_rels/document.xml.rels', '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + ''.join(relationships) + '</Relationships>')
            archive.writestr('word/document.xml', f'''<?xml version="1.0" encoding="UTF-8"?>
<w:document {namespaces}><w:body>{''.join(document_parts)}<w:sectPr><w:pgMar w:top="0" w:right="0" w:bottom="0" w:left="0"/></w:sectPr></w:body></w:document>''')
            for name, image in media.items():
                archive.writestr(f'word/media/{name}', image)
        return output.getvalue()


def convert(source, target, content, *, libreoffice_path=None, timeout=120):
    source = source.lower().lstrip('.')
    target = target.lower().lstrip('.')
    _validate(source, content)
    if source == target:
        return content
    if source in IMAGE_FORMATS and target in IMAGE_FORMATS:
        return _image_image(source, target, content)
    if target == 'pdf' and source in IMAGE_FORMATS:
        return _image_pdf(source, content, timeout)
    if source == 'pdf' and target in {'png', 'jpg', 'jpeg'}:
        return _pdf_images('png' if target == 'png' else 'jpeg', content, timeout)
    if source == 'pdf' and target == 'txt':
        return _pdf_text(content, timeout)
    if source == 'pdf' and target == 'docx':
        return _pdf_docx(content, timeout)
    if target in {'txt', 'md', 'markdown'}:
        return _text(source, target, content)
    if (source in OFFICE_FORMATS | {'txt', 'md', 'markdown'} and target in OFFICE_FORMATS | {'pdf', 'txt'}) or \
            (source == 'pdf' and target == 'docx'):
        executable = libreoffice_path or shutil.which('soffice') or shutil.which('libreoffice')
        if not executable:
            raise ConversionError('This conversion is temporarily unavailable.', 503)
        return _libreoffice(source, target, content, executable, timeout)
    raise ConversionError(f'Conversion from {source.upper()} to {target.upper()} is not supported.', 422)