"""AliothPress — WebMCP server module.

Isolated so app.py stays untouched except for one registration line. Holds the
parts that have no existing route to reuse:

  GET /.well-known/webmcp          — agent discovery manifest
  GET /admin/api/agent/list        — read/filter posts, pages, or forms

Write actions (create post/page, build menu) deliberately do NOT live here:
they reuse the existing human routes, which return clean JSON when the caller
asks for it (see _after_create / admin_menus in app.py). That keeps a single
source of truth for content creation instead of a parallel, drift-prone copy.

Models and helpers are imported lazily inside handlers to avoid an import cycle
with app.py.
"""

from flask import jsonify, request


def init_app(app, csrf=None, login_required=None, limiter=None):
    """Register WebMCP routes on the given Flask app.

    csrf / login_required / limiter are passed in from app.py so we apply the
    same protections the rest of the admin uses without re-importing app at
    module load time.
    """

    def _exempt(view):
        return csrf.exempt(view) if csrf else view

    def _login(view):
        return login_required(view) if login_required else view

    def _limit(rule):
        return limiter.limit(rule) if limiter else (lambda view: view)

    # ── Discovery manifest ────────────────────────────────────────────────
    @app.route('/.well-known/webmcp', methods=['GET'])
    @_exempt
    def webmcp_manifest():
        from models import Settings
        admin_on = Settings.get('webmcp_admin_enabled', 'false') == 'true'
        public_on = Settings.get('webmcp_public_enabled', 'false') == 'true'

        surfaces = []
        if public_on:
            surfaces.append({
                'name': 'public',
                'description': 'Content search (search_site) plus per-form tools '
                               '(describe_form_<id>, submit_form_<id>) on pages that '
                               'render a form.',
                'auth': 'none',
                'tools': ['search_site', 'describe_form_<id>', 'submit_form_<id>'],
                'endpoints': {'search': '/api/public/search', 'overview': '/llms.txt'}
            })
        if admin_on:
            # The four ai_* tools run through the CMS's own AI provider and
            # are announced only when a working provider key is configured —
            # the manifest never advertises tools that cannot run.
            ai_tools = []
            try:
                from app import get_ai_config
                if get_ai_config()[2] is None:
                    ai_tools = ['ai_generate_content', 'ai_translate_text',
                                'ai_generate_page_blocks', 'ai_optimize_seo']
            except Exception:
                pass
            surfaces.append({
                'name': 'admin',
                'scope': '/admin',
                'description': 'Content and site tools (list/read/search content, create '
                               'and update posts/pages, media upload and attach, page '
                               'blocks, build menu, navigate; server-side ai_* generation '
                               'tools appear only when an AI provider is configured — all '
                               'other tools need none). Available only inside an '
                               'authenticated admin session; the agent acts as the '
                               'signed-in user under existing permissions. Agents can save '
                               'drafts but can never publish — publishing is human-only.',
                'auth': 'session',
                'tools': ai_tools + [
                    'list_content', 'get_content', 'search_content',
                    'check_slug', 'create_post', 'create_page', 'update_post', 'update_page',
                    'create_posts_batch', 'create_pages_batch',
                    'upload_image', 'upload_images_batch', 'list_media', 'attach_image',
                    'create_builder_page', 'get_page_blocks', 'set_page_blocks',
                    'describe_builder_blocks', 'build_menu', 'open_admin_section'
                ]
            })

        return jsonify({
            'webmcp_version': '0.1',
            'name': Settings.get('site_name', 'AliothPress'),
            'description': 'AliothPress site exposing WebMCP tools to AI agents.',
            'enabled': admin_on or public_on,
            'registration': 'in-page (document.modelContext / navigator.modelContext)',
            # Always-on read endpoints, open to any agent without a token.
            'content_search': '/api/public/search',
            'overview': '/llms.txt',
            'surfaces': surfaces,
            'docs': 'https://aliothpress.com'
        })

    # ── Agent read / list (read-only, safe) ───────────────────────────────
    @app.route('/admin/api/agent/list', methods=['GET'])
    @_login
    def webmcp_agent_list():
        """List posts, pages, or forms with optional filters — read-only.

        Query params: type=post|page|form, status=, language=, q=, limit=
        With id=N, returns the FULL editable record of a single post/page —
        exactly the fields the human edit form holds. This is what lets an
        agent do read-merge-write through the human edit routes without
        wiping fields it didn't mean to touch (those routes overwrite every
        field from the submitted form, same as a human's prefilled form).
        Submissions are intentionally NOT listed here (visitor PII).
        """
        from flask import abort
        from models import Post, Page, Form, Settings

        # Honest "dormant by default": behave as if the route doesn't exist
        # unless the owner has turned on admin agent access.
        if Settings.get('webmcp_admin_enabled', 'false') != 'true':
            abort(404)

        ctype = request.args.get('type', 'post')

        # ── Single-item detail (read-only) ────────────────────────────────
        item_id = request.args.get('id')
        if item_id:
            model = {'post': Post, 'page': Page}.get(ctype)
            if model is None:
                return jsonify({'success': False,
                                'error': 'detail supports type=post or type=page'}), 400
            try:
                it = model.query.get(int(item_id))
            except (TypeError, ValueError):
                it = None
            if it is None:
                return jsonify({'success': False, 'error': 'not_found'}), 404

            import json as _json
            try:
                tags_list = _json.loads(it.tags) if it.tags else []
            except (ValueError, TypeError):
                tags_list = []

            detail = {
                'id': it.id, 'title': it.title, 'slug': it.slug,
                'content': it.content or '', 'excerpt': it.excerpt or '',
                'language': it.language, 'status': it.status,
                'meta_title': it.meta_title or '', 'meta_description': it.meta_description or '',
                'meta_keywords': it.meta_keywords or '',
                'schema_type': it.schema_type or '', 'schema_author': it.schema_author or '',
                'schema_publisher': it.schema_publisher or '',
                'og_title': it.og_title or '', 'og_description': it.og_description or '',
                'og_image': it.og_image or '',
                'twitter_title': it.twitter_title or '', 'twitter_description': it.twitter_description or '',
                'twitter_image': it.twitter_image or '',
                'featured_image': it.featured_image or '',
                'featured_image_alt': it.featured_image_alt or '',
                'featured_image_caption': it.featured_image_caption or '',
                'tags': ', '.join(str(x) for x in tags_list),
                'category': it.category or '',
                'show_author': bool(getattr(it, 'show_author', False)),
                'show_date': bool(getattr(it, 'show_date', False)),
                'faq_json': it.faq_json or '',
                'translation_group': it.translation_group,
            }
            if ctype == 'page':
                detail['template'] = getattr(it, 'template', 'default') or 'default'
                detail['use_builder'] = bool(getattr(it, 'use_builder', False))
                detail['is_posts_page'] = bool(getattr(it, 'is_posts_page', False))
            return jsonify({'success': True, 'type': ctype, 'item': detail})
        status = request.args.get('status', '').strip()
        language = request.args.get('language', '').strip()
        q = request.args.get('q', '').strip()
        try:
            limit = min(int(request.args.get('limit', 50)), 200)
        except (TypeError, ValueError):
            limit = 50

        model = {'post': Post, 'page': Page, 'form': Form}.get(ctype)
        if model is None:
            return jsonify({'success': False, 'error': 'type must be post, page, or form'}), 400

        query = model.query
        if language and hasattr(model, 'language'):
            query = query.filter(model.language == language)
        if status and hasattr(model, 'status'):
            query = query.filter(model.status == status)
        if q and hasattr(model, 'title'):
            query = query.filter(model.title.ilike(f'%{q}%'))

        order_col = getattr(model, 'updated_at', None) or getattr(model, 'id')
        total = query.count()
        items = query.order_by(order_col.desc()).limit(limit).all()

        def row(it):
            d = {'id': it.id, 'title': getattr(it, 'title', '')}
            # translation_group lets an agent see which items are language
            # versions of one another (and pass a source id to the translate
            # flow) — it is an opaque UUID, not sensitive data.
            for attr in ('slug', 'status', 'language', 'translation_group',
                         'form_type', 'is_active'):
                if hasattr(it, attr):
                    d[attr] = getattr(it, attr)
            return d

        # `count` kept for backward compatibility (= size of this page).
        # `total` is the number of matches BEFORE the limit was applied;
        # `truncated` makes silent cut-offs impossible to misread — agents
        # were interpreting count as the total and concluding items vanished.
        return jsonify({'success': True, 'type': ctype, 'count': len(items),
                        'returned': len(items), 'total': total,
                        'truncated': total > len(items), 'limit': limit,
                        'items': [row(it) for it in items]})

    # ── Public content search (always-on, like /llms.txt) ─────────────────
    @app.route('/api/public/search', methods=['GET'])
    @_exempt
    @_limit("30 per minute")
    def webmcp_public_search():
        """Structured content search for AI agents on the public site.

        Not a human search box — a machine-readable surface so an agent can ask
        "what do you have about X" and get titles, URLs, and snippets in one call.
        Searches published posts and pages (including page-builder blocks and
        FAQs); also matches blog categories and tags.

        This is read-only public content access — open to any agent without a
        token, the same as /llms.txt, the sitemap, and RSS. (The WebMCP *tool*
        wrapper that calls it is what the public toggle controls; the data itself
        is always reachable.) Rate-limited to curb scraping.

        Query params: q (required), language, type=post|page (default both), limit.
        """
        import json as _json
        import re as _re
        from html import unescape as _unescape
        from models import Post, Page

        q = (request.args.get('q', '') or '').strip()
        if not q:
            # Bare fetch (no ?q=) — answer 200 with a self-description instead
            # of 400. Two audiences: an agent that discovered the endpoint via
            # the manifest learns how to call it in one round-trip, and Google
            # (allowed to crawl /api/public/ by robots.txt) sees a healthy
            # page instead of filing a "Blocked due to 4xx" report entry.
            # X-Robots-Tag: noindex on /api/ keeps it out of the index.
            return jsonify({
                'success': False,
                'error': 'q (search query) is required',
                'usage': {
                    'method': 'GET',
                    'params': {
                        'q': 'search query (required)',
                        'language': 'optional language code filter',
                        'type': "optional: 'post' or 'page' (default: both)",
                        'limit': 'optional, max results (default 10, cap 50)',
                    },
                    'example': '/api/public/search?q=themes&language=en&limit=5',
                },
            }), 200
        language = (request.args.get('language', '') or '').strip()
        type_filter = (request.args.get('type', '') or '').strip().lower()
        try:
            limit = min(int(request.args.get('limit', 10)), 50)
        except (TypeError, ValueError):
            limit = 10

        base_url = request.url_root.rstrip('/')
        like = f'%{q}%'
        ql = q.lower()

        def strip_html(html):
            if not html:
                return ''
            text = _re.sub(r'<[^>]+>', ' ', html)
            text = _unescape(text)
            return _re.sub(r'\s+', ' ', text).strip()

        def builder_plaintext(blocks_json):
            """Readable text of a page-builder page (headings, text, accordion/FAQ,
            tabs, tables, quotes, captions…), reusing the translation extractor so
            new block types are covered automatically. Strips HTML, drops meta keys."""
            if not blocks_json:
                return ''
            try:
                from app import _extract_builder_texts
                data = _json.loads(blocks_json)
                blocks = data.get('blocks', []) if isinstance(data, dict) else data
                entries = _extract_builder_texts(blocks)
            except Exception:
                return strip_html(blocks_json)  # graceful fallback
            chunks = []

            def collect(obj):
                if isinstance(obj, str):
                    chunks.append(obj)
                elif isinstance(obj, dict):
                    for k, v in obj.items():
                        if k in ('id', 'type'):
                            continue
                        collect(v)
                elif isinstance(obj, list):
                    for v in obj:
                        collect(v)

            collect(entries)

            # _extract_builder_texts (the translation extractor) skips raw html/
            # code blocks by design. For search we still want their text, so walk
            # the raw blocks too. Normally redundant — page.content already holds
            # the rendered flat HTML — but this covers stale/empty-content pages.
            def collect_code(blist):
                for b in (blist or []):
                    bt = b.get('type')
                    bd = b.get('data', {}) or {}
                    if bt in ('html', 'code') and bd.get('code'):
                        chunks.append(bd['code'])
                    if bt == 'columns':
                        for col in bd.get('columns', []):
                            collect_code(col.get('blocks', []))
                    elif bt == 'section':
                        collect_code(bd.get('blocks', []))
                    elif bt == 'slideshow':
                        for sl in bd.get('slides', []):
                            collect_code(sl.get('blocks', []))
            collect_code(blocks)

            return strip_html(' '.join(chunks))

        def body_text(it):
            """Full searchable body. page.content already holds the rendered flat
            HTML of all blocks (incl. html/code) for builder pages saved normally;
            fall back to extracted builder text only if content is empty/stale."""
            text = strip_html(it.content)
            if len(text) < 20 and getattr(it, 'use_builder', False) and getattr(it, 'blocks', None):
                bt = builder_plaintext(it.blocks)
                if bt:
                    return bt
            return text

        def make_snippet(text, max_len=200):
            if not text:
                return ''
            low = text.lower()
            pos = low.find(ql)
            if pos == -1:
                return (text[:max_len] + ('…' if len(text) > max_len else ''))
            start = max(0, pos - 60)
            end = min(len(text), pos + len(q) + 120)
            snip = text.strip() if start == 0 else '…' + text[start:end].strip()
            if end < len(text):
                snip += '…'
            return snip

        def faq_hit(faq_json):
            """Return matching FAQ Q/A text if the query appears in the FAQ."""
            if not faq_json:
                return None
            try:
                pairs = _json.loads(faq_json)
            except (ValueError, TypeError):
                return None
            for pair in pairs or []:
                blob = (str(pair.get('question', '')) + ' ' + str(pair.get('answer', ''))).strip()
                if ql in blob.lower():
                    return blob
            return None

        def search_model(model, ctype):
            cond = (model.title.ilike(like) | model.content.ilike(like) |
                    model.excerpt.ilike(like) | model.meta_description.ilike(like) |
                    model.faq_json.ilike(like))
            if hasattr(model, 'tags'):
                cond = cond | model.tags.ilike(like)
            if hasattr(model, 'category'):
                cond = cond | model.category.ilike(like)
            if hasattr(model, 'blocks'):
                cond = cond | model.blocks.ilike(like)   # page-builder content
            query = model.query.filter(model.status == 'published').filter(cond)
            if language:
                query = query.filter(model.language == language)
            return [(it, ctype) for it in query.limit(60).all()]

        rows = []
        if type_filter in ('', 'post', 'both'):
            rows += search_model(Post, 'post')
        if type_filter in ('', 'page', 'both'):
            rows += search_model(Page, 'page')

        # Build, rank, and de-duplicate across translation groups.
        results = []
        for it, ctype in rows:
            title = it.title or ''
            body = body_text(it)
            matched_in, snippet = None, ''
            if ql in title.lower():
                matched_in, snippet = 'title', make_snippet(it.excerpt or body)
            else:
                fq = faq_hit(it.faq_json)
                if fq:
                    matched_in, snippet = 'faq', make_snippet(fq)
                elif it.excerpt and ql in it.excerpt.lower():
                    matched_in, snippet = 'excerpt', make_snippet(it.excerpt)
                elif it.meta_description and ql in it.meta_description.lower():
                    matched_in, snippet = 'meta', make_snippet(it.meta_description)
                elif ql in body.lower():
                    matched_in, snippet = 'content', make_snippet(body)
                else:
                    matched_in, snippet = 'other', make_snippet(it.excerpt or body)
            rank = {'title': 0, 'faq': 1, 'excerpt': 2, 'meta': 3, 'content': 4, 'other': 5}[matched_in]
            date = getattr(it, 'published_at', None) or getattr(it, 'updated_at', None) or getattr(it, 'created_at', None)
            # How many times the query occurs in the visible text (title +
            # body). body was already computed above, so this costs two
            # string scans — no extra DB work. When the hit came from a
            # field outside the visible text (meta, tags, FAQ) the count
            # would be 0, which reads as a lie next to a returned result,
            # so it is floored to 1. Additive field: existing consumers
            # (site_search plugin, agents) simply ignore it until they
            # choose to use it, e.g. "12 matches" badges or letting an
            # agent pick the densest page on a topic.
            match_count = title.lower().count(ql) + body.lower().count(ql)
            results.append({
                'rank': rank,
                'group': getattr(it, 'translation_group', None),
                'data': {
                    'type': ctype, 'title': title, 'url': f'{base_url}/{it.slug}',
                    'language': getattr(it, 'language', None),
                    'date': date.isoformat() if date else None,
                    'matched_in': matched_in, 'snippet': snippet,
                    'match_count': max(1, match_count)
                }
            })

        results.sort(key=lambda r: (r['rank'], r['data']['date'] or ''), reverse=False)
        results.sort(key=lambda r: r['data']['date'] or '', reverse=True)
        results.sort(key=lambda r: r['rank'])

        seen_groups, deduped = set(), []
        for r in results:
            g = r['group']
            if g and g in seen_groups:
                continue
            if g:
                seen_groups.add(g)
            deduped.append(r['data'])
            if len(deduped) >= limit:
                break

        # Matching blog categories / tags → hub URLs (cheap, useful for "articles about X").
        topics = []
        cats = {c[0] for c in Post.query.with_entities(Post.category)
                .filter(Post.status == 'published', Post.category.ilike(like)).all() if c[0]}
        for c in sorted(cats):
            topics.append({'kind': 'category', 'name': c, 'url': f'{base_url}/category/{c}'})
        tag_rows = Post.query.with_entities(Post.tags).filter(
            Post.status == 'published', Post.tags.ilike(like)).all()
        tag_set = set()
        for (raw,) in tag_rows:
            try:
                for tg in (_json.loads(raw) or []):
                    if ql in str(tg).lower():
                        tag_set.add(str(tg))
            except (ValueError, TypeError):
                continue
        for tg in sorted(tag_set):
            topics.append({'kind': 'tag', 'name': tg, 'url': f'{base_url}/tag/{tg}'})

        return jsonify({
            'success': True, 'query': q, 'count': len(deduped),
            'results': deduped, 'topics': topics[:20],
            'overview': f'{base_url}/llms.txt'
        })
