"""Extract text from a course-plan source and embed it into pgvector.

Supported inputs:

* ``kind='file'``  — PDF (preferred via ``pypdf`` then ``PyPDF2``), DOCX
  (via ``python-docx``), plain text, or any text MIME we can decode.
* ``kind='url'``   — fetched with ``requests``; HTML is reduced to text
  via a tiny BeautifulSoup4 path when available, otherwise raw text.
* ``kind='text'``  — used as-is.

Indexed chunks are stored under ``content_type='course_plan_source'``
with ``entity_id=plan_id`` so the existing ``resources.search`` tool
can scope retrieval to a single plan. We never raise — failures are
recorded on the source row and surfaced to the UI through the status /
error fields. That way one bad PDF doesn't block the rest.
"""

from __future__ import annotations

import base64
import io
import logging
from datetime import datetime

_logger = logging.getLogger(__name__)


def _extract_pdf(payload: bytes) -> str:
    """Best-effort PDF text extraction.

    Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older,
    still common). Both raise on encrypted PDFs we can't decrypt — we
    swallow that and let the caller record the error.
    """
    try:
        from pypdf import PdfReader  # type: ignore
    except ImportError:
        try:
            from PyPDF2 import PdfReader  # type: ignore
        except ImportError as exc:
            raise RuntimeError(
                'PDF parser not installed (pip install pypdf)',
            ) from exc
    reader = PdfReader(io.BytesIO(payload))
    pages = []
    for page in reader.pages:
        try:
            pages.append(page.extract_text() or '')
        except Exception:
            pages.append('')
    return '\n\n'.join(p for p in pages if p).strip()


def _extract_docx(payload: bytes) -> str:
    try:
        import docx  # type: ignore
    except ImportError as exc:
        raise RuntimeError(
            'DOCX parser not installed (pip install python-docx)',
        ) from exc
    document = docx.Document(io.BytesIO(payload))
    return '\n'.join(p.text for p in document.paragraphs if p.text).strip()


def _extract_html(html: str) -> str:
    """Strip tags. Uses BeautifulSoup if available, else a regex fallback."""
    try:
        from bs4 import BeautifulSoup  # type: ignore
        soup = BeautifulSoup(html, 'html.parser')
        for tag in soup(['script', 'style', 'noscript']):
            tag.decompose()
        text = soup.get_text(separator='\n')
    except ImportError:
        import re
        text = re.sub(r'<[^>]+>', ' ', html)
    lines = [line.strip() for line in text.splitlines()]
    return '\n'.join(line for line in lines if line)


def _fetch_url(url: str) -> tuple[str, str]:
    """Fetch ``url`` and return ``(content_type, text)``."""
    try:
        import requests  # type: ignore
    except ImportError as exc:
        raise RuntimeError(
            'requests not installed (pip install requests)',
        ) from exc
    resp = requests.get(url, timeout=30, headers={
        'User-Agent': 'EnCoach-CoursePlan-Indexer/1.0',
    })
    resp.raise_for_status()
    ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip().lower()
    if ctype == 'application/pdf':
        return ctype, _extract_pdf(resp.content)
    if 'html' in ctype:
        return ctype, _extract_html(resp.text)
    if ctype.startswith('text/') or not ctype:
        return ctype or 'text/plain', resp.text
    raise RuntimeError(f'Unsupported content-type for URL: {ctype}')


class SourceIndexer:
    """Extract text from a source row and (re-)index it to pgvector."""

    CONTENT_TYPE = 'course_plan_source'

    def __init__(self, env):
        self.env = env

    def _extract(self, source) -> str:
        """Return raw extracted text — or raise if extraction fails."""
        if source.kind == 'text':
            return (source.inline_text or '').strip()

        if source.kind == 'resource':
            # Dereference the library resource at extraction time. This
            # keeps the binary in one place (``encoach.resource``) so an
            # admin update — re-uploading a corrected PDF, fixing a URL,
            # changing the linked file — propagates to every plan that
            # grounds on it on the next reindex.
            res = source.resource_id
            if not res or not res.exists():
                raise ValueError(
                    'Linked library resource is missing or was deleted.',
                )
            rtype = (res.type or '').lower()
            file_name = (res.name or '') + (
                f'.{rtype}' if rtype in ('pdf', 'docx') and not (res.name or '').lower().endswith(('.pdf', '.docx')) else ''
            )
            # Persist the resolved metadata on the source row so the UI
            # can render a meaningful "indexed N chunks from X.pdf" line
            # without having to rejoin the resource table on every read.
            updates = {}
            if not source.name:
                updates['name'] = res.name or f'Resource #{res.id}'
            if not source.file_name:
                updates['file_name'] = file_name
            if updates:
                source.write(updates)

            if res.file:
                payload = base64.b64decode(res.file)
                if not payload:
                    raise ValueError('Library resource has an empty file.')
                if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
                    return _extract_pdf(payload)
                if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
                    return _extract_docx(payload)
                # Fall back to plain-text decoding for txt/md/csv/json.
                if file_name.lower().endswith((
                    '.txt', '.md', '.markdown', '.csv', '.json', '.xml',
                    '.log', '.rst',
                )):
                    return payload.decode('utf-8', errors='replace').strip()
                # Best-effort: try PDF first, then DOCX, then UTF-8.
                for fn in (_extract_pdf, _extract_docx):
                    try:
                        text = fn(payload)
                        if text:
                            return text
                    except Exception:
                        continue
                try:
                    return payload.decode('utf-8', errors='replace').strip()
                except Exception as exc:
                    raise RuntimeError(
                        f'Cannot decode library resource binary: {exc}',
                    ) from exc

            if res.url:
                _, text = _fetch_url(res.url)
                return text or ''

            raise ValueError(
                'Library resource has neither a file nor a URL to index.',
            )

        if source.kind == 'url':
            url = (source.url or '').strip()
            if not url:
                raise ValueError('URL is empty')
            mime, text = _fetch_url(url)
            if not source.mime_type:
                source.mime_type = mime
            return text or ''

        if source.kind == 'file':
            payload = source.get_decoded_file()
            if not payload:
                raise ValueError('No file payload to index')
            mime = (source.mime_type or '').lower()
            name = (source.file_name or '').lower()
            if mime == 'application/pdf' or name.endswith('.pdf'):
                return _extract_pdf(payload)
            if (mime in (
                    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                    'application/msword',
                ) or name.endswith('.docx') or name.endswith('.doc')):
                return _extract_docx(payload)
            # Whitelist plain-text-shaped uploads explicitly. Anything else
            # (xlsx, png, mp3, zip, …) must be rejected with a clear error
            # rather than silently UTF-8-decoded into garbage that we'd
            # then "successfully" embed and surface as a usable RAG source.
            if (mime.startswith('text/')
                    or mime in ('application/json', 'application/xml',
                                'application/csv')
                    or name.endswith(('.txt', '.md', '.markdown', '.csv',
                                      '.json', '.xml', '.log', '.rst'))):
                try:
                    return payload.decode('utf-8', errors='replace').strip()
                except Exception as exc:
                    raise RuntimeError(f'Cannot decode file: {exc}') from exc
            raise ValueError(
                f'Unsupported file type for RAG indexing: '
                f'mime={mime!r} name={source.file_name!r}. '
                f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
            )

        raise ValueError(f'Unknown source kind: {source.kind!r}')

    def index(self, source) -> dict:
        """Run extraction + embedding for one source row."""
        from odoo.addons.encoach_vector.services.embedding_service import (
            EmbeddingService,
        )
        source.write({'status': 'indexing', 'error': False})

        try:
            text = self._extract(source)
        except Exception as exc:
            source.write({
                'status': 'failed',
                'error': str(exc)[:500],
                'chunks_count': 0,
                'extracted_chars': 0,
            })
            _logger.warning('Extract failed for source %s: %s', source.id, exc)
            return {'status': 'failed', 'error': str(exc)}

        if not text or not text.strip():
            source.write({
                'status': 'failed',
                'error': 'Extracted no text from source',
                'chunks_count': 0,
                'extracted_chars': 0,
            })
            return {'status': 'failed', 'error': 'empty'}

        try:
            svc = EmbeddingService(self.env)
            metadata = {
                'plan_id': source.plan_id.id,
                'source_id': source.id,
                'kind': source.kind,
                'title': source.name or source.file_name or source.url or '',
                'entity_id': source.plan_id.id,
            }
            chunks = svc.upsert(
                self.CONTENT_TYPE, source.id, text, metadata,
            )
        except Exception as exc:
            source.write({
                'status': 'failed',
                'error': str(exc)[:500],
            })
            _logger.exception('Embedding failed for source %s', source.id)
            return {'status': 'failed', 'error': str(exc)}

        source.write({
            'status': 'indexed',
            'error': False,
            'chunks_count': len(chunks),
            'extracted_chars': len(text),
            'indexed_at': datetime.utcnow(),
        })
        return {
            'status': 'indexed',
            'chunks_count': len(chunks),
            'extracted_chars': len(text),
        }
