"""
schema_org.py — Schema.org (JSON-LD) graph builders.

Generates structured data as Python dicts, to be serialized by Jinja's
`|tojson` filter. This replaces the old string-concatenation approach in
Jinja templates, which produced a whole class of syntax bugs:

  * Missing quotes around values (name without "" etc).
  * Trailing commas from conditional `{% if %}` blocks.
  * "Leading commas" (,"foo": ...) used as a workaround for the above.
  * Relative image URLs leaking into JSON-LD from DB fields.

The builders here return plain dicts. Jinja's `|tojson` takes care of
escaping, quoting and proper JSON syntax — it's impossible to produce
broken JSON this way.

Entity @ids are kept byte-for-byte identical to what the old templates
emitted (e.g. "{url_root}#organization", "{url_root}#/person/{id}")
so that Google/Bing treat the nodes as the SAME entities across the
migration — no "new entity discovered" noise in Search Console.
"""

from urllib.parse import urljoin

from flask import request


# ─────────────────────────────────────────────────────────────────────────
# URL normalization
# ─────────────────────────────────────────────────────────────────────────

def absolute_url(url, base=None):
    """Return an absolute URL.

    Accepts:
      * already-absolute URLs (``http://``, ``https://``, protocol-relative ``//``)
        — returned unchanged;
      * root-relative paths (``/avatar.png``) — joined with ``request.url_root``;
      * bare filenames / relative paths — joined with ``request.url_root``;
      * falsy / non-string — returns ``''`` (empty string) so Jinja renders
        nothing rather than the literal ``"None"``.

    Pass an explicit ``base`` to override ``request.url_root`` (useful outside
    a request context, e.g. background jobs).
    """
    if not url or not isinstance(url, str):
        return ''
    url = url.strip()
    if not url:
        return ''
    if url.startswith(('http://', 'https://', '//')):
        return url
    root = base or request.url_root
    return urljoin(root, url)


def _image_object(url, width=None, height=None, caption=None):
    """Build an ImageObject dict, or ``None`` if the URL is missing/invalid."""
    abs_url = absolute_url(url)
    if not abs_url:
        return None
    obj = {'@type': 'ImageObject', 'url': abs_url}
    if caption:
        obj['caption'] = caption
    if width and height:
        obj['width'] = width
        obj['height'] = height
    return obj


def _id_for_organization():
    return f'{request.url_root}#organization'


def _id_for_person(author):
    """Stable per-author identifier; 0 for anonymous."""
    author_id = author.id if author else 0
    return f'{request.url_root}#person-{author_id}'


# ─────────────────────────────────────────────────────────────────────────
# Entity builders (one per Schema.org @type)
# ─────────────────────────────────────────────────────────────────────────

def build_organization(site_name, settings=None, org_sameas=None):
    """Organization node, keyed by ``{url_root}#organization``.

    ``settings`` is a mapping with .get() — either the Settings class or a plain
    dict. ``org_sameas`` is a list of social/profile URLs or ``None``.
    """
    get = settings.get if settings is not None else (lambda *a, **k: None)

    org = {
        '@type': 'Organization',
        '@id': _id_for_organization(),
        'name': site_name,
        'url': request.url_root.rstrip('/'),
    }

    # Legal Name / Founding Year — saved in Settings → Organization since the
    # AEO section was added, but never emitted until now. Both help AI systems
    # and knowledge graphs verify the organization entity, alongside sameAs.
    legal_name = (get('org_legal_name') or '').strip()
    if legal_name:
        org['legalName'] = legal_name

    # schema.org foundingDate accepts ISO-8601 dates; a bare year ("2019")
    # is valid year-precision ISO-8601 and is what the settings field asks for.
    founding_year = str(get('org_founding_year') or '').strip()
    if founding_year:
        org['foundingDate'] = founding_year

    logo_url = get('site_logo')
    if logo_url:
        logo = _image_object(
            logo_url,
            width=get('site_logo_width') or None,
            height=get('site_logo_height') or None,
        )
        if logo:
            org['logo'] = logo

    if org_sameas:
        org['sameAs'] = list(org_sameas)

    return org


def build_person(author, site_name, author_sameas=None, schema_author_name=None):
    """Person node, keyed by ``{url_root}#/person/<id>``.

    ``author`` may be ``None`` — we then fall back to ``site_name`` and id 0
    (matches the previous template behaviour exactly).

    ``schema_author_name`` overrides the display name (comes from
    ``page.schema_author`` / ``post.schema_author``).
    """
    if schema_author_name:
        name = schema_author_name
    elif author:
        name = author.get_display_name()
    else:
        name = site_name

    person = {
        '@type': 'Person',
        '@id': _id_for_person(author),
        'name': name,
    }

    if author:
        bio = getattr(author, 'bio', None)
        if bio:
            person['description'] = bio

        avatar = absolute_url(getattr(author, 'avatar_url', None))
        if avatar:
            # Kept as a plain URL string to match the shape the old template
            # produced. Google accepts both string and ImageObject here.
            person['image'] = avatar

    if author_sameas:
        person['sameAs'] = list(author_sameas)

    return person


def _content_image(featured_image, featured_image_alt, images_map):
    """Build the ImageObject for a post/page featured image."""
    if not featured_image:
        return None
    width = height = None
    if images_map:
        img = images_map.get(featured_image)
        if img and getattr(img, 'width', None) and getattr(img, 'height', None):
            width, height = img.width, img.height
    return _image_object(
        featured_image,
        width=width,
        height=height,
        caption=featured_image_alt or None,
    )


_SPEAKABLE = {
    '@type': 'SpeakableSpecification',
    'cssSelector': ['.ap-prose > p:first-of-type'],
}


def build_webpage(page, images_map=None):
    """WebPage (or custom ``page.schema_type``) node for a Page."""
    canonical = page.canonical_url or request.url

    node = {
        '@type': page.schema_type or 'WebPage',
        '@id': canonical,
        'name': page.meta_title or page.title,
        'headline': page.title,
    }

    description = page.meta_description or page.excerpt
    if description:
        node['description'] = description

    node['url'] = canonical
    node['inLanguage'] = page.language

    image = _content_image(page.featured_image, page.featured_image_alt, images_map)
    if image:
        node['image'] = image

    if page.published_at:
        node['datePublished'] = page.published_at.isoformat()
    if page.updated_at:
        node['dateModified'] = page.updated_at.isoformat()

    node['author'] = {'@id': _id_for_person(page.author)}
    node['publisher'] = {'@id': _id_for_organization()}
    node['mainEntityOfPage'] = {'@type': 'WebPage', '@id': request.url}

    if page.meta_keywords:
        node['keywords'] = page.meta_keywords

    # NOTE: ``availableLanguage`` is intentionally omitted here — Schema.org
    # does NOT define it on WebPage (it belongs to Service/ContactPoint).
    # Language alternates are signalled to search engines via the
    # <link rel="alternate" hreflang="..."> tags in <head>.

    node['speakable'] = _SPEAKABLE
    return node


def build_blogposting(post, images_map=None):
    """BlogPosting (or custom ``post.schema_type``) node for a Post."""
    canonical = post.canonical_url or request.url

    node = {
        '@type': post.schema_type or 'BlogPosting',
        '@id': canonical,
        'headline': post.meta_title or post.title,
    }

    description = post.meta_description or post.excerpt
    if description:
        node['description'] = description

    node['url'] = canonical
    node['inLanguage'] = post.language

    image = _content_image(post.featured_image, post.featured_image_alt, images_map)
    if image:
        node['image'] = image

    date_published = post.published_at or post.created_at
    if date_published:
        node['datePublished'] = date_published.isoformat()
    if post.updated_at:
        node['dateModified'] = post.updated_at.isoformat()

    node['author'] = {'@id': _id_for_person(post.author)}
    node['publisher'] = {'@id': _id_for_organization()}
    node['mainEntityOfPage'] = {'@type': 'WebPage', '@id': request.url}

    if getattr(post, 'category', None):
        node['articleSection'] = post.category
    if post.meta_keywords:
        node['keywords'] = post.meta_keywords

    node['speakable'] = _SPEAKABLE
    return node


def build_faq(faq_items):
    """FAQPage node or ``None`` if there are no items.

    ``faq_items`` — iterable of dicts with ``question``/``answer`` keys
    (as returned by ``Post.get_faq_list()`` / ``Page.get_all_faq_items()``).
    """
    if not faq_items:
        return None

    main_entity = []
    for faq in faq_items:
        question = faq.get('question') if isinstance(faq, dict) else getattr(faq, 'question', None)
        answer = faq.get('answer') if isinstance(faq, dict) else getattr(faq, 'answer', None)
        if not question or not answer:
            continue
        main_entity.append({
            '@type': 'Question',
            'name': question,
            'acceptedAnswer': {'@type': 'Answer', 'text': answer},
        })

    if not main_entity:
        return None
    return {'@type': 'FAQPage', 'mainEntity': main_entity}


# ─────────────────────────────────────────────────────────────────────────
# Graph assemblers (one per page template)
# ─────────────────────────────────────────────────────────────────────────

def _wrap_graph(nodes):
    """Wrap a list of entity dicts into a @context/@graph envelope,
    dropping any ``None`` entries (convenient for optional blocks like FAQ)."""
    return {
        '@context': 'https://schema.org',
        '@graph': [n for n in nodes if n is not None],
    }


def build_page_graph(page, site_name, settings=None,
                     org_sameas=None, author_sameas=None, images_map=None):
    """Full @graph for ``public/page.html``: Organization + Person + WebPage + (optional FAQ)."""
    return _wrap_graph([
        build_organization(site_name, settings, org_sameas),
        build_person(
            page.author, site_name,
            author_sameas=author_sameas,
            schema_author_name=getattr(page, 'schema_author', None),
        ),
        build_webpage(page, images_map=images_map),
        build_faq(page.get_all_faq_items()) if hasattr(page, 'get_all_faq_items') else None,
    ])


def build_post_graph(post, site_name, settings=None,
                     org_sameas=None, author_sameas=None, images_map=None):
    """Full @graph for ``public/post.html``: Organization + Person + BlogPosting + (optional FAQ)."""
    return _wrap_graph([
        build_organization(site_name, settings, org_sameas),
        build_person(
            post.author, site_name,
            author_sameas=author_sameas,
            schema_author_name=getattr(post, 'schema_author', None),
        ),
        build_blogposting(post, images_map=images_map),
        build_faq(post.get_faq_list()) if hasattr(post, 'get_faq_list') else None,
    ])


def build_website_graph(site_name, site_tagline, settings=None, available_languages=None,
                        org_sameas=None):
    """WebSite + Organization @graph for ``public/index.html`` (fallback
    homepage without a homepage Page).

    The Organization node is built by the same ``build_organization()`` used
    on posts and pages, so ``legalName``, ``foundingDate``, ``logo`` and
    ``sameAs`` stay consistent site-wide. The WebSite node references it via
    ``publisher`` → ``@id``, mirroring how post/page graphs link entities.

    ``available_languages`` — iterable of dicts with a ``code`` key, or of
    bare language codes. If more than one language is present, it becomes
    ``inLanguage`` on the WebSite.
    """
    get = settings.get if settings is not None else (lambda *a, **k: None)

    organization = build_organization(site_name, settings, org_sameas)

    node = {
        '@type': 'WebSite',
        'name': site_name,
        'description': site_tagline,
        'url': request.url_root,
        'publisher': {'@id': organization['@id']},
    }

    logo_url = get('site_logo')
    if logo_url:
        image = _image_object(
            logo_url,
            width=get('site_logo_width') or None,
            height=get('site_logo_height') or None,
        )
        if image:
            node['image'] = image

    if available_languages:
        codes = []
        for lang in available_languages:
            if isinstance(lang, dict):
                code = lang.get('code')
            else:
                code = lang
            if code:
                codes.append(code)
        if len(codes) > 1:
            # inLanguage on WebSite IS valid per Schema.org.
            node['inLanguage'] = codes

    return {
        '@context': 'https://schema.org',
        '@graph': [organization, node],
    }


def build_itemlist(items, url_builder, name_attr='title'):
    """Simple ItemList node.

    ``items`` — iterable of model instances (posts/pages).
    ``url_builder`` — callable(item) -> absolute URL string.
    ``name_attr`` — attribute name to read for each item's display name.
    """
    elements = []
    for idx, item in enumerate(items, start=1):
        elements.append({
            '@type': 'ListItem',
            'position': idx,
            'url': url_builder(item),
            'name': getattr(item, name_attr),
        })
    return {
        '@context': 'https://schema.org',
        '@type': 'ItemList',
        'itemListElement': elements,
    }


def build_collection_page(name, description=None, url=None, item_list=None):
    """CollectionPage node used by tag / category / hub pages."""
    node = {
        '@context': 'https://schema.org',
        '@type': 'CollectionPage',
        'name': name,
        'url': url or request.url,
    }
    if description:
        node['description'] = description
    if item_list is not None:
        # item_list here is an already-built ItemList dict (without @context).
        # Strip the @context if present so it doesn't appear nested.
        nested = dict(item_list)
        nested.pop('@context', None)
        node['mainEntity'] = nested
    return node
