import json
import mimetypes
import os
import secrets
import logging
from pathlib import Path

from django.conf import settings
from django.db.models import F
from django.http import FileResponse, JsonResponse, Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.dateparse import parse_datetime
from django.views import View

from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.response import Response

from .models import FileUpload
from .serializers import FileUploadSerializer

logger = logging.getLogger(__name__)


class FileSaveError(Exception):
    """Raised when an uploaded file could not be fully persisted to disk."""


def _get_upload_folder():
    folder = settings.FILE_UPLOADS_FOLDER
    Path(folder).mkdir(parents=True, exist_ok=True)
    return folder


def _cleanup_partial(path):
    try:
        if os.path.exists(path):
            os.remove(path)
    except OSError:
        logger.warning("Failed to clean up partial upload file %s", path, exc_info=True)


def _save_uploaded_file(f):
    """Save an in-memory upload to FILE_UPLOADS_FOLDER and return a FileUpload instance.

    Writes to a temporary path first, fsyncs, and verifies the byte count on
    disk matches what was uploaded *before* creating the DB record or moving
    the file into its final place. This prevents "phantom" records that point
    at a missing/truncated file if the underlying storage (e.g. a flaky NFS
    mount) acknowledges a write that never actually lands on disk.
    """
    folder = _get_upload_folder()
    ext = Path(f.name).suffix.lower()
    stored_name = f"{secrets.token_hex(16)}{ext}"
    dest_path = os.path.join(folder, stored_name)
    tmp_path = f"{dest_path}.part"
    try:
        with open(tmp_path, 'wb') as dst:
            for chunk in f.chunks():
                dst.write(chunk)
            dst.flush()
            os.fsync(dst.fileno())
    except OSError as exc:
        _cleanup_partial(tmp_path)
        raise FileSaveError(f"Failed to write \"{f.name}\" to storage: {exc}") from exc

    actual_size = os.path.getsize(tmp_path)
    if actual_size != f.size:
        _cleanup_partial(tmp_path)
        raise FileSaveError(
            f"Upload of \"{f.name}\" is incomplete ({actual_size}/{f.size} bytes written)."
        )

    os.rename(tmp_path, dest_path)

    guessed = mimetypes.guess_type(f.name)[0]
    # Clients (notably the iOS uploader) often send a generic
    # "application/octet-stream" Content-Type even for media files. When that
    # happens, prefer the type inferred from the filename extension so the
    # file is previewable/streamable in the UI.
    if f.content_type and f.content_type != 'application/octet-stream':
        mime_type = f.content_type
    else:
        mime_type = guessed or f.content_type or 'application/octet-stream'
    return FileUpload.objects.create(
        name=f.name,
        stored_name=stored_name,
        mime_type=mime_type,
        size=actual_size,
    )


def _stream_file(file_path, mime_type, disposition, range_header=None):
    """Return a streaming response for a media file that honours HTTP Range.

    Browsers send a ``Range: bytes=start-end`` header to seek inside
    ``<video>`` / ``<audio>`` elements. Django's ``FileResponse`` ignores it,
    so we parse the header ourselves and reply with ``206 Partial Content`` and
    a ``Content-Range`` header. Without this, the media element can only play
    sequentially and seeking fails.
    """
    size = os.path.getsize(file_path)

    # No Range header → serve the whole file (200 OK).
    if not range_header or not range_header.startswith('bytes='):
        response = FileResponse(open(file_path, 'rb'), content_type=mime_type)
        response['Content-Length'] = size
        response['Accept-Ranges'] = 'bytes'
        response['Content-Disposition'] = disposition
        return response

    # Parse "bytes=start-end" (end optional; multiple ranges not supported here).
    spec = range_header[len('bytes='):]
    # Ignore multipart ranges; pick the first range.
    if ',' in spec:
        spec = spec.split(',', 1)[0].strip()
    try:
        start_str, end_str = spec.split('-', 1)
        start = int(start_str) if start_str.strip() else 0
        end = int(end_str) if end_str.strip() else size - 1
    except (ValueError, IndexError):
        response = HttpResponse(status=416)  # Range Not Satisfiable
        response['Content-Range'] = f'bytes */{size}'
        return response

    # Clamp to file bounds.
    if start < 0:
        start = 0
    if end >= size:
        end = size - 1
    if start > end:
        response = HttpResponse(status=416)
        response['Content-Range'] = f'bytes */{size}'
        return response

    length = end - start + 1

    def _range_iterator(_fh, _start, _length):
        _fh.seek(_start)
        remaining = _length
        chunk_size = 64 * 1024
        while remaining > 0:
            read = min(chunk_size, remaining)
            data = _fh.read(read)
            if not data:
                break
            remaining -= len(data)
            yield data

    fh = open(file_path, 'rb')
    response = FileResponse(_range_iterator(fh, start, length), content_type=mime_type, status=206)
    response['Content-Length'] = length
    response['Content-Range'] = f'bytes {start}-{end}/{size}'
    response['Accept-Ranges'] = 'bytes'
    response['Content-Disposition'] = disposition
    # Ensure the file handle is closed when the response finalises.
    response.close = fh.close
    return response


# ── UI Views ──────────────────────────────────────────────────────────────────

class FileListView(View):
    def get(self, request):
        files = FileUpload.objects.all()
        return render(request, 'links/files/list.html', {'files': files})


class FileUploadView(View):
    def post(self, request):
        is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
        uploaded = request.FILES.getlist('files')
        if not uploaded:
            if is_ajax:
                return JsonResponse({'error': 'No files provided'}, status=400)
            return redirect('file-list')
        results = []
        errors = []
        for f in uploaded:
            try:
                record = _save_uploaded_file(f)
            except FileSaveError as exc:
                logger.error("Upload failed for %s: %s", f.name, exc)
                errors.append({'name': f.name, 'error': str(exc)})
                continue
            results.append({
                'id': str(record.pk),
                'name': record.name,
                'mime_type': record.mime_type,
                'size': record.size,
                'formatted_size': record.formatted_size(),
                'is_public': record.is_public,
                'public_url': record.public_url,
                'expires_at': record.expires_at.isoformat() if record.expires_at else None,
                'download_count': record.download_count,
                'created_at': record.created_at.isoformat(),
                'download_url': record.download_url,
                'is_image': record.is_image,
            })
        if is_ajax:
            status_code = 200 if results else 500
            return JsonResponse({'uploaded': results, 'errors': errors}, status=status_code)
        return redirect('file-list')


class FileDownloadView(View):
    def get(self, request, pk, filename=''):
        record = get_object_or_404(FileUpload, pk=pk)
        if not os.path.exists(record.file_path):
            raise Http404("File not found on disk")
        FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1)
        inline = record.is_image or record.mime_type.startswith('video/') or record.mime_type.startswith('audio/')
        disposition = 'inline' if inline else f'attachment; filename="{record.name}"'
        return _stream_file(record.file_path, record.mime_type, disposition, request.headers.get('Range'))


class FileDeleteView(View):
    def post(self, request, pk):
        record = get_object_or_404(FileUpload, pk=pk)
        if os.path.exists(record.file_path):
            os.remove(record.file_path)
        record.delete()
        if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
            return JsonResponse({'deleted': str(pk)})
        return redirect('file-list')


class FileTogglePublicView(View):
    def post(self, request, pk):
        record = get_object_or_404(FileUpload, pk=pk)
        if record.is_public:
            record.is_public = False
            record.save(update_fields=['is_public', 'updated_at'])
            return JsonResponse({'is_public': False, 'public_url': None})
        record.is_public = True
        record.save(update_fields=['is_public', 'updated_at'])
        return JsonResponse({
            'is_public': True,
            'public_url': record.public_url,
        })


class FileSetExpiryView(View):
    def post(self, request, pk):
        record = get_object_or_404(FileUpload, pk=pk)
        try:
            data = json.loads(request.body)
        except (json.JSONDecodeError, ValueError):
            return JsonResponse({'error': 'Invalid JSON'}, status=400)
        expires_at = data.get('expires_at')
        if expires_at:
            dt = parse_datetime(expires_at)
            if not dt:
                return JsonResponse({'error': 'Invalid datetime format. Use ISO 8601.'}, status=400)
            record.expires_at = dt
        else:
            record.expires_at = None
        record.save(update_fields=['expires_at', 'updated_at'])
        return JsonResponse({
            'expires_at': record.expires_at.isoformat() if record.expires_at else None,
            'is_expired': record.is_expired,
        })


class PublicFileView(View):
    """Serve uploaded files via /public/files/{uuid}-{name}.

    Access rules:
    - Internal network (192.168.x.x, 10.x.x.x, 172.16-31.x.x): serve any file
    - External (go.junv.cc, xgo.junv.cc): only serve is_public=True files
    - Videos and images stream inline; other files force download.
    """

    INTERNAL_NETS = ('192.168.', '10.', '172.16.', '172.17.', '172.18.',
                     '172.19.', '172.20.', '172.21.', '172.22.', '172.23.',
                     '172.24.', '172.25.', '172.26.', '172.27.', '172.28.',
                     '172.29.', '172.30.', '172.31.')

    def _is_internal(self, request):
        xff = request.META.get('HTTP_X_FORWARDED_FOR', '')
        client_ip = xff.split(',')[0].strip() if xff else request.META.get('REMOTE_ADDR', '')
        return any(client_ip.startswith(net) for net in self.INTERNAL_NETS)

    def get(self, request, pk, filename=''):
        record = get_object_or_404(FileUpload, pk=pk)

        # External access: only public files
        if not self._is_internal(request) and not record.is_public:
            raise Http404("This file is private")

        if record.is_expired:
            raise Http404("This link has expired")
        if not os.path.exists(record.file_path):
            raise Http404("File not found")

        FileUpload.objects.filter(pk=record.pk).update(download_count=F('download_count') + 1)

        # Inline for media, attachment for everything else
        inline = record.is_image or record.mime_type.startswith('video/') or record.mime_type.startswith('audio/')
        disposition = 'inline' if inline else f'attachment; filename="{record.name}"'

        return _stream_file(record.file_path, record.mime_type, disposition, request.headers.get('Range'))


# ── REST API ViewSet ──────────────────────────────────────────────────────────

class FileUploadViewSet(viewsets.ModelViewSet):
    queryset = FileUpload.objects.all()
    serializer_class = FileUploadSerializer
    parser_classes = [MultiPartParser, FormParser, JSONParser]
    http_method_names = ['get', 'post', 'delete', 'head', 'options']

    def create(self, request, *args, **kwargs):
        uploaded = request.FILES.getlist('files')
        if not uploaded:
            return Response({'error': 'No files provided. Use "files" field.'}, status=status.HTTP_400_BAD_REQUEST)
        created = []
        errors = []
        for f in uploaded:
            try:
                created.append(_save_uploaded_file(f))
            except FileSaveError as exc:
                logger.error("Upload failed for %s: %s", f.name, exc)
                errors.append({'name': f.name, 'error': str(exc)})
        if not created:
            return Response(
                {'error': 'Failed to save uploaded file(s).', 'errors': errors},
                status=status.HTTP_500_INTERNAL_SERVER_ERROR,
            )
        serializer = self.get_serializer(created, many=True)
        return Response(serializer.data, status=status.HTTP_201_CREATED)

    def destroy(self, request, *args, **kwargs):
        record = self.get_object()
        if os.path.exists(record.file_path):
            os.remove(record.file_path)
        record.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

    @action(detail=True, methods=['post'], url_path='toggle-public')
    def toggle_public(self, request, pk=None):
        record = self.get_object()
        if record.is_public:
            record.is_public = False
            record.save(update_fields=['is_public', 'updated_at'])
            return Response({'is_public': False, 'public_url': None})
        record.is_public = True
        record.save(update_fields=['is_public', 'updated_at'])
        return Response({
            'is_public': True,
            'public_url': record.public_url,
        })

    @action(detail=True, methods=['post'], url_path='set-expiry')
    def set_expiry(self, request, pk=None):
        record = self.get_object()
        expires_at = request.data.get('expires_at')
        if expires_at:
            dt = parse_datetime(str(expires_at))
            if not dt:
                return Response({'error': 'Invalid datetime. Use ISO 8601.'}, status=status.HTTP_400_BAD_REQUEST)
            record.expires_at = dt
        else:
            record.expires_at = None
        record.save(update_fields=['expires_at', 'updated_at'])
        return Response(self.get_serializer(record).data)

    @action(detail=True, methods=['get'])
    def download(self, request, pk=None):
        record = self.get_object()
        if not os.path.exists(record.file_path):
            raise Http404("File not found")
        FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1)
        inline = record.is_image or record.mime_type.startswith('video/') or record.mime_type.startswith('audio/')
        disposition = 'inline' if inline else f'attachment; filename="{record.name}"'
        return _stream_file(record.file_path, record.mime_type, disposition, request.headers.get('Range'))


def import_image_view(request, image_url):
    """Proxy-and-cache an external image via /import/images/<path:image_url>.

    The image_url path component has no scheme (e.g. 'example.com/path/img.jpg').
    On first request the view:
      1. Creates a FileUpload stub in the database (is_public=True, source_url set).
      2. Fires a background thread to download and save the file.
      3. Immediately redirects to the original URL so the image is visible right away.
    On subsequent requests, once the file is saved locally, the view redirects to
    the stored public URL instead — no more dependency on the original host.

    If the background download fails, the stub record is deleted automatically so no
    size=0 ghost appears in the file list. The next visit to the same URL will create
    a fresh stub and retry. A periodic APScheduler task (`retry_stuck_image_imports`)
    also reschedules any stubs left behind by killed threads.
    """
    import hashlib
    import posixpath
    from threading import Thread
    from .tasks import download_and_save_image

    # Build the canonical source URL, preserving query string.
    # Django's <path:> converter only captures the path component; query params
    # like ?format=jpg&name=900x900 end up in QUERY_STRING and must be re-attached.
    query_string = request.META.get('QUERY_STRING', '')
    source_url = f'https://{image_url}'
    if query_string:
        source_url = f'{source_url}?{query_string}'

    url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:20]
    # Strip query string for filename derivation
    filename = posixpath.basename(image_url.split('?')[0]) or f'image_{url_hash}'

    # Derive extension from filename; fall back to .jpg for bare names
    _, ext = posixpath.splitext(filename)
    if not ext:
        ext = '.jpg'
        filename = f'{filename}{ext}'

    stored_name = f'import_{url_hash}{ext}'

    # Try to find an existing record for this URL (idempotent)
    record = FileUpload.objects.filter(source_url=source_url).first()

    if record is None:
        # Create the stub immediately so we have a stable public URL
        record = FileUpload.objects.create(
            name=filename,
            stored_name=stored_name,
            mime_type=f'image/{ext.lstrip(".") or "jpeg"}',
            size=0,
            is_public=True,
            source_url=source_url,
        )
        logger.info(f"import_image_view: created FileUpload {record.pk} for {source_url}")

    # If the file is already on disk, serve from local storage
    if os.path.exists(record.file_path):
        return redirect(record.public_url)

    # File not yet saved — kick off (or re-kick) the background download
    thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True)
    thread.start()

    # Redirect to the original URL as a temporary placeholder while download runs
    return redirect(source_url)
