from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse
import json
from pathlib import Path
import os
import logging
import time
import threading
import io
import mimetypes
import shutil
import subprocess
import tempfile
import zipfile
import re
from collections import defaultdict
from email.parser import BytesParser
from email.policy import default
from conversion_service import ConversionError as ServiceConversionError
from conversion_service import convert as convert_file

# Optional Redis support for distributed rate limiting
try:
    import redis
    _REDIS_AVAILABLE = True
except Exception:
    redis = None
    _REDIS_AVAILABLE = False

ROOT = Path(__file__).resolve().parent
MAX_CONVERSION_BYTES = int(os.environ.get('MAX_CONVERSION_BYTES', 100 * 1024 * 1024))
CONVERSION_TIMEOUT = int(os.environ.get('CONVERSION_TIMEOUT_SECONDS', 120))
LIBREOFFICE_PATH = os.environ.get('LIBREOFFICE_PATH')


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


def _find_libreoffice():
    candidates = [LIBREOFFICE_PATH] if LIBREOFFICE_PATH else []
    candidates.extend(['soffice', 'libreoffice'])
    for candidate in candidates:
        if candidate and (os.path.isabs(candidate) or shutil.which(candidate)):
            return candidate
    raise ConversionError('LibreOffice is not installed or LIBREOFFICE_PATH is invalid.', 503)


def _validate_document(filename, content):
    suffix = Path(filename).suffix.lower()
    if suffix == '.pdf':
        if not content.startswith(b'%PDF-'):
            raise ConversionError('The uploaded file is not a valid PDF.')
    elif suffix == '.docx':
        try:
            with zipfile.ZipFile(io.BytesIO(content)) as archive:
                names = set(archive.namelist())
                if 'word/document.xml' not in names or '[Content_Types].xml' not in names:
                    raise ConversionError('The uploaded file is not a valid DOCX.')
                if any(name.startswith('/') or '..' in Path(name).parts for name in names):
                    raise ConversionError('The uploaded DOCX contains unsafe paths.')
        except zipfile.BadZipFile as exc:
            raise ConversionError('The uploaded file is not a valid DOCX.') from exc
    else:
        raise ConversionError('Only PDF and DOCX files can use this conversion endpoint.')


def _run_document_conversion(filename, content, target):
    source_suffix = Path(filename).suffix.lower()
    expected_target = 'docx' if source_suffix == '.pdf' else 'pdf'
    if target != expected_target:
        raise ConversionError(f'Unsupported conversion: {source_suffix[1:]} to {target}.')
    _validate_document(filename, content)
    executable = _find_libreoffice()

    with tempfile.TemporaryDirectory(prefix='convert-hau-') as temp_dir:
        work_dir = Path(temp_dir)
        source = work_dir / f'input{source_suffix}'
        source.write_bytes(content)
        profile = work_dir / 'profile'
        output_dir = work_dir / 'output'
        output_dir.mkdir()
        profile.mkdir()
        command = [
            executable,
            f'-env:UserInstallation={profile.as_uri()}',
            '--headless',
            '--convert-to', target,
            '--outdir', str(output_dir),
            str(source),
        ]
        try:
            completed = subprocess.run(
                command,
                capture_output=True,
                timeout=CONVERSION_TIMEOUT,
                check=False,
                shell=False,
            )
        except subprocess.TimeoutExpired as exc:
            raise ConversionError('Document conversion timed out.', 504) from exc
        except OSError as exc:
            logging.exception('Unable to start LibreOffice')
            raise ConversionError('The document conversion engine could not be started.', 503) from exc

        if completed.returncode != 0:
            logging.error('LibreOffice conversion failed: %s', completed.stderr[-2000:].decode(errors='replace'))
            raise ConversionError('LibreOffice could not convert this document.', 422)

        converted = output_dir / f'input.{target}'
        if not converted.is_file() or converted.stat().st_size == 0:
            raise ConversionError('The conversion engine did not produce an output document.', 422)
        return converted.read_bytes()


def _multipart_file(handler, body):
    content_type = handler.headers.get('Content-Type', '')
    if not content_type.lower().startswith('multipart/form-data'):
        raise ConversionError('Expected a multipart/form-data upload.')
    boundary_match = re.search(r'boundary=(?:"([^"]+)"|([^;]+))', content_type, re.IGNORECASE)
    if not boundary_match:
        raise ConversionError('The upload boundary is invalid.')
    boundary = (boundary_match.group(1) or boundary_match.group(2)).strip().encode()
    for part in body.split(b'--' + boundary):
        header_end = part.find(b'\r\n\r\n')
        if header_end < 0:
            continue
        headers = part[:header_end].decode('utf-8', errors='replace')
        filename_match = re.search(r'filename="([^"]*)"|filename=([^;\r\n]+)', headers, re.IGNORECASE)
        if not filename_match:
            continue
        filename = Path(filename_match.group(1) or filename_match.group(2)).name
        content = part[header_end + 4:]
        if content.endswith(b'\r\n'):
            content = content[:-2]
        if filename and content:
            return filename, content
    raise ConversionError('No file was included in the upload.')


class RateLimiter:
    def __init__(self, max_requests=120, window_seconds=60, redis_url=None):
        self.max_requests = max_requests
        self.window = window_seconds
        self.lock = threading.Lock()
        self.redis = None
        if redis_url and _REDIS_AVAILABLE:
            try:
                self.redis = redis.StrictRedis.from_url(redis_url, decode_responses=True)
            except Exception:
                self.redis = None

        # in-memory fallback
        self.hits = defaultdict(list)

    def _allow_redis(self, key: str) -> bool:
        # Use time-window key: increments per window bucket
        bucket = int(time.time() // self.window)
        rkey = f"rl:{key}:{bucket}"
        try:
            current = self.redis.incr(rkey)
            if current == 1:
                self.redis.expire(rkey, self.window + 1)
            return current <= self.max_requests
        except Exception:
            return None

    def allow(self, key: str) -> bool:
        if self.redis:
            ok = self._allow_redis(key)
            if ok is not None:
                return ok

        # in-memory fallback
        now = time.time()
        with self.lock:
            q = self.hits[key]
            # drop old timestamps
            while q and q[0] <= now - self.window:
                q.pop(0)
            if len(q) < self.max_requests:
                q.append(now)
                return True
            return False


# Global rate limiter (per-IP)
RATE_LIMITER = RateLimiter(
    max_requests=int(os.environ.get("RATE_LIMIT_MAX", 120)),
    window_seconds=int(os.environ.get("RATE_LIMIT_WINDOW", 60)),
    redis_url=os.environ.get("REDIS_URL"),
)


class SiteHandler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(ROOT), **kwargs)

    def end_headers(self):
        # Add security and CORS headers for every response
        self.send_header('X-Content-Type-Options', 'nosniff')
        self.send_header('X-Frame-Options', 'DENY')
        self.send_header('Referrer-Policy', 'no-referrer-when-downgrade')
        # Content Security Policy - allow required assets, local vendors, CDNs, workers, and fonts
        default_csp = (
            "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; "
            "script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; "
            "worker-src 'self' blob: https:; "
            "style-src 'self' 'unsafe-inline' https:; "
            "font-src 'self' data: https:; "
            "img-src 'self' data: blob: https:; "
            "connect-src 'self' data: blob: https:;"
        )
        csp = os.environ.get('CSP', default_csp)
        self.send_header('Content-Security-Policy', csp)
        allowed_origin = os.environ.get('ALLOWED_ORIGIN', 'http://localhost:3000')
        self.send_header('Access-Control-Allow-Origin', allowed_origin)
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-Source-Format, X-Convert-To')
        super().end_headers()

    def do_OPTIONS(self):
        # Respond to CORS preflight quickly
        self.send_response(204)
        self.end_headers()

    def do_POST(self):
        try:
            client_ip = self.client_address[0]
            if not RATE_LIMITER.allow(client_ip):
                self._send_json({'error': 'rate limit exceeded'}, status=429)
                return
            parsed = urlparse(self.path)
            if parsed.path != '/api/convert':
                self._send_json({'error': 'not found'}, status=404)
                return

            length_header = self.headers.get('Content-Length')
            try:
                length = int(length_header or '0')
            except ValueError as exc:
                raise ConversionError('Invalid upload length.') from exc
            if length <= 0:
                raise ConversionError('The upload is empty.')
            if length > MAX_CONVERSION_BYTES:
                raise ConversionError('The uploaded file is too large.', 413)
            body = self.rfile.read(length)
            if len(body) != length:
                raise ConversionError('The upload was incomplete.')

            filename, content = _multipart_file(self, body)
            if len(content) > MAX_CONVERSION_BYTES:
                raise ConversionError('The uploaded file is too large.', 413)
            source = (self.headers.get('X-Source-Format') or Path(filename).suffix).lower().strip('.')
            target = (self.headers.get('X-Convert-To') or '').lower().strip()
            try:
                converted = convert_file(
                    source, target, content,
                    libreoffice_path=LIBREOFFICE_PATH,
                    timeout=CONVERSION_TIMEOUT,
                )
            except ServiceConversionError as exc:
                raise ConversionError(str(exc), exc.status) from exc
            safe_stem = Path(filename).stem.replace('"', '').replace('\r', '').replace('\n', '')
            output_name = f'{safe_stem}_converted.{target}'
            content_type = (
                'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
                if target == 'docx' else 'application/pdf'
            )
            self.send_response(200)
            self.send_header('Content-Type', content_type)
            self.send_header('Content-Length', str(len(converted)))
            self.send_header('Content-Disposition', f'attachment; filename="{output_name}"')
            self.end_headers()
            self.wfile.write(converted)
        except ConversionError as exc:
            self._send_json({'error': str(exc)}, status=exc.status)
        except Exception:
            logging.exception('Unhandled conversion request')
            self._send_json({'error': 'internal conversion error'}, status=500)

    def do_GET(self):
        try:
            client_ip = self.client_address[0]
            if not RATE_LIMITER.allow(client_ip):
                logging.warning('Rate limit exceeded for %s', client_ip)
                self._send_json({'error': 'rate limit exceeded'}, status=429)
                return

            parsed = urlparse(self.path)
            if parsed.path == '/api/health':
                self._send_json({
                    'status': 'ok',
                    'service': 'convert-hau',
                    'path': parsed.path,
                })
                return

            if parsed.path == '/api/info':
                self._send_json({
                    'app': 'Convert Hau',
                    'mode': 'static-site-with-python-backend',
                    'ads': 'disabled',
                })
                return

            public_files = {
                '/sitemap.xml': ('sitemap.xml', 'application/xml; charset=utf-8'),
                '/googledd7691f90074abfe.html': ('googledd7691f90074abfe.html', 'text/html; charset=utf-8'),
            }
            if parsed.path in public_files:
                filename, content_type = public_files[parsed.path]
                file_path = ROOT / filename
                if not file_path.is_file():
                    self._send_json({'error': 'not found'}, status=404)
                    return
                body = file_path.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', content_type)
                self.send_header('Content-Length', str(len(body)))
                self.send_header('Cache-Control', 'public, max-age=3600')
                self.end_headers()
                self.wfile.write(body)
                return

            super().do_GET()
        except Exception:
            logging.exception('Unhandled exception processing request')
            try:
                self._send_json({'error': 'internal server error'}, status=500)
            except Exception:
                # If responding fails, just close connection
                pass

    def _send_json(self, payload, status=200):
        body = json.dumps(payload).encode('utf-8')
        self.send_response(status)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        try:
            self.wfile.write(body)
        except BrokenPipeError:
            logging.debug('Client closed connection before response finished')

    def log_message(self, format, *args):
        # Route built-in logging to the standard logging module
        logging.info('%s - %s', self.client_address[0], format % args)


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
    port = int(os.environ.get("PORT", 8001))
    server = ThreadingHTTPServer(("0.0.0.0", port), SiteHandler)
    logging.info('Serving Convert Hau on port %d', port)

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        logging.info('Stopping server...')
    finally:
        server.server_close()