package builtin

// document_image.go — what text to embed for an image chunk (RFC BU phase 4).
//
// Pixels are not text, so unlike mermaid an image genuinely needs a model. That
// makes it the one chunk type whose searchable text may not exist at write time,
// and the whole design of this file follows from refusing to let that block a write.
//
// TWO INDEPENDENT SOURCES, composed:
//
//   - the CAPTION — the author's own alt text. Human language, needs no model, and
//     available the instant the chunk is written.
//   - the DESCRIPTION — generated by a vision model, persisted next to the asset.
//
// The caption is why an image is searchable immediately rather than only after a
// describe pass has run. Waiting for the model would mean that every image is
// unsearchable for as long as the operator has not swept, and on a deployment with
// no vision model reachable, forever — for text the author already supplied.
//
// WHY THE DESCRIPTION IS PERSISTED rather than regenerated per embed:
//
//   - it is model output, so regenerating yields DIFFERENT text. An index that
//     silently re-ranks on every re-embed is worse than one that is merely stale.
//   - it is auditable: an operator can read why an image matched a query.
//   - it survives an embedder swap. Re-embedding then costs one vector operation
//     instead of a vision call per image, which over thousands of images is the
//     difference between a re-embed being routine and being a budget decision.

import (
	"context"
	"fmt"
	"regexp"
	"strings"
	"time"

	"github.com/denn-gubsky/loomcycle/internal/sqlmem"
)

// assetDescription reads a chunk's persisted vision description, or "" when there
// is none — no asset, no describe pass yet, or a pass that produced nothing.
//
// BEST-EFFORT BY DESIGN. A store fault returns "" rather than an error because the
// only caller is on the embed path, where the alternative is worse: failing the
// read would either fail an author's write over a missing description, or embed the
// caption while pretending no description exists. Returning "" degrades to
// exactly the phase-4a behaviour — caption-only — which is a correct index, just a
// less complete one.
func (d *Document) assetDescription(ctx context.Context, key sqlmem.ScopeKey, chunkID string) string {
	if d.SqlMem == nil {
		return ""
	}
	res, err := d.query(ctx, key, `SELECT description FROM chunk_assets WHERE chunk_id = ? LIMIT 1`, chunkID)
	if err != nil || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
		return ""
	}
	return strings.TrimSpace(asStr(res.Rows[0][0]))
}

// assetDescriptionState reads the description AND whether a pass has run, which
// get_asset needs in order to report the three states apart. at == 0 means no
// describe pass has examined this image.
func (d *Document) assetDescriptionState(ctx context.Context, key sqlmem.ScopeKey, chunkID string) (description string, at int64) {
	if d.SqlMem == nil {
		return "", 0
	}
	res, err := d.query(ctx, key,
		`SELECT description, described_at FROM chunk_assets WHERE chunk_id = ? LIMIT 1`, chunkID)
	if err != nil || len(res.Rows) == 0 || len(res.Rows[0]) < 2 {
		return "", 0
	}
	return strings.TrimSpace(asStr(res.Rows[0][0])), int64(asInt(res.Rows[0][1]))
}

// setAssetDescription persists a generated description next to the asset bytes.
//
// described_at is stamped even for an EMPTY description, which is the point of
// having it as a separate column: "a model looked and produced nothing useful" and
// "nothing has looked yet" are different states, and a sweep that cannot tell them
// apart re-describes the same unproductive image on every run.
func (d *Document) setAssetDescription(ctx context.Context, key sqlmem.ScopeKey, chunkID, description string) error {
	return d.exec(ctx, key,
		`UPDATE chunk_assets SET description = ?, described_at = ? WHERE chunk_id = ?`,
		strings.TrimSpace(description), time.Now().UnixNano(), chunkID)
}

// mdScaffoldOnlyRe matches text that is ENTIRELY Markdown scaffolding: a fence
// opener/closer with an optional language tag, a horizontal rule, or bare heading
// hashes. Anchored and whole-string, so text that merely BEGINS with a fence — a real
// code example — is untouched.
var mdScaffoldOnlyRe = regexp.MustCompile("(?s)\\A\\s*(?:`{3,}[A-Za-z0-9_+-]*|~{3,}[A-Za-z0-9_+-]*|-{3,}|\\*{3,}|_{3,}|#{1,6})\\s*\\z")

// indexableText is the ONE test for whether a string is worth embedding, applied to a
// chunk body and to a title fallback alike. Returns the trimmed text, or "" when
// there is nothing a query could meaningfully match.
//
// TWO REJECTIONS, both learned from real data rather than reasoned into:
//
//  1. Markdown scaffolding. A heading-split import turns a fence line into its own
//     chunk, so corpora contain chunks whose whole body is "```sh", "---" or "#".
//     Measured on the reference deployment, a user asking "which medicine do I take"
//     got "---" (0.477), "```sh" (0.452) and "#" (0.451) ranked ABOVE the fact naming
//     their medication (0.434). A short token embeds near the centroid of everything,
//     so it ranks mid-high for EVERY query — it does not merely waste a row, it
//     outranks real answers.
//  2. No letters — "42", "1.2.3", "..." carry no language.
//
// Neither rejection subsumes the other, which is why both are here: "```sh" HAS
// letters, so the letter test alone lets it through; "42" is not scaffolding, so the
// scaffold test alone lets it through.
//
// ONE function on purpose. When a body is rejected the caller falls back to the
// TITLE, and a chunk whose body is a fence line usually has a scaffold-ish title too
// — applying a weaker rule there would just move the noise from one field to the
// other, which is exactly what a first attempt here did.
func indexableText(s string) string {
	t := strings.TrimSpace(s)
	if t == "" || mdScaffoldOnlyRe.MatchString(t) {
		return ""
	}
	if !strings.ContainsFunc(t, func(r rune) bool {
		return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
	}) {
		return ""
	}
	return t
}

// IndexableText is the exported form, for the admin sweeps.
//
// EXPORTED BECAUSE THE JUDGEMENT MUST BE SHARED, and it was not: the write path applied
// this rule while the embedding backfill derived its text by trimming alone, so the
// sweep could still CREATE the scaffolding embeddings the writer rejects — and the purge
// that removes them would then not recognise them either. Three places deciding
// "indexable" independently is how they drift; one function is the fix.
func IndexableText(s string) string { return indexableText(s) }

// UndescribedAsset is one image awaiting a description: enough to make the vision
// call and to know whether it is worth making.
type UndescribedAsset struct {
	ChunkID    string `json:"chunk_id"`
	DocumentID string `json:"document_id"`
	MediaType  string `json:"media_type"`
	Size       int    `json:"size"`
	// Caption is the author's alt text, already embedded. Passed to the describe
	// pass so the prompt can avoid restating what the author said.
	Caption string `json:"caption,omitempty"`
}

// ListUndescribedAssets returns image assets no describe pass has examined.
//
// RESUMABLE WITHOUT STATE, like the phase-2 backfill: every described asset gets a
// described_at and drops out of this set, so a pass that dies half-way leaves the
// rest still listed. There is no cursor to persist and therefore no way for a crash
// to double-describe an image or skip one.
//
// Ordered by chunk_id rather than by size or recency, so repeated calls walk the
// same sequence — an operator re-invoking after a failure sees progress rather than
// a reshuffled queue.
func (d *Document) ListUndescribedAssets(ctx context.Context, scope string, limit int) ([]UndescribedAsset, error) {
	if d.Store == nil || d.SqlMem == nil {
		return nil, fmt.Errorf("Document assets: requires SQL Memory (set LOOMCYCLE_SQLMEM_ENABLED=1)")
	}
	key, mscope, err := d.resolveScope(ctx, scope)
	if err != nil {
		return nil, err
	}
	if err := d.ensureSchema(ctx, key); err != nil {
		return nil, err
	}
	if limit <= 0 || limit > 500 {
		limit = 100
	}
	res, err := d.query(ctx, key,
		`SELECT a.chunk_id, c.document_id, a.media_type, a.size FROM chunk_assets a
		 JOIN chunks c ON c.id = a.chunk_id
		 WHERE a.described_at IS NULL ORDER BY a.chunk_id LIMIT ?`, limit)
	if err != nil {
		return nil, err
	}
	out := make([]UndescribedAsset, 0, len(res.Rows))
	for _, row := range res.Rows {
		if len(row) < 4 {
			continue
		}
		a := UndescribedAsset{
			ChunkID:    asStr(row[0]),
			DocumentID: asStr(row[1]),
			MediaType:  asStr(row[2]),
			Size:       asInt(row[3]),
		}
		// Best-effort: a caption that cannot be read only costs the prompt some
		// context, so it must not drop the asset from the pass.
		if cb, err := d.readBody(ctx, mscope, key.ScopeID, a.ChunkID); err == nil {
			if typ, _, _, _ := classifyMediaBody(cb.Body); typ == "" {
				a.Caption = strings.TrimSpace(cb.Body)
			}
		}
		out = append(out, a)
	}
	return out, nil
}

// SetAssetDescription persists a description and RE-EMBEDS the chunk so the new
// text actually reaches the index.
//
// The re-embed is the whole point and is easy to omit: without it the description
// sits in the database, get_asset reports the image as described, and it is still
// unsearchable — a state that looks correct from every surface an operator would
// check.
func (d *Document) SetAssetDescription(ctx context.Context, scope, chunkID, description string) error {
	if d.Store == nil || d.SqlMem == nil {
		return fmt.Errorf("Document assets: requires SQL Memory (set LOOMCYCLE_SQLMEM_ENABLED=1)")
	}
	key, mscope, err := d.resolveScope(ctx, scope)
	if err != nil {
		return err
	}
	if err := d.ensureSchema(ctx, key); err != nil {
		return err
	}
	if err := d.setAssetDescription(ctx, key, chunkID, description); err != nil {
		return err
	}
	// Re-embed from the STORED body, not from a caller-supplied caption: the body is
	// the authority on what the caption currently is, and a describe pass must not
	// be able to rewrite it.
	cb, err := d.readBody(ctx, mscope, key.ScopeID, chunkID)
	if err != nil {
		return err
	}
	d.embedBody(ctx, direntTenant(ctx), mscope, key, chunkBodyKey(chunkID), chunkID, "image", cb.Body)
	return nil
}

// imageEmbedText composes the searchable text for an image chunk.
//
// Returns "" when there is nothing to index — no title, no caption, no description.
// The caller then stores no vector at all, which is the honest state: the image is
// unsearchable but not lost, and it becomes searchable when any of the three arrives.
// Embedding a placeholder would be worse than embedding nothing, because a row that
// exists ranks against every query.
//
// THE TITLE IS INCLUDED HERE, NOT USED AS A FALLBACK, and an image is the one type
// where that is right. Everywhere else the title is a fallback because a body usually
// RESTATES its heading, so appending both double-weights the same words. An image has
// no body to restate it — its "body" is the caption — so that rationale simply does not
// apply. And the title routinely carries what a vision model provably cannot know:
// "Loomcycle Logo 1280x640" tells you whose logo it is, while the generated description
// says "five round characters around a central spool". Dropping the title to preserve a
// rule whose reason is absent would lose the only searchable mention of the brand.
func imageEmbedText(title, caption, description string) string {
	title = strings.TrimSpace(title)
	caption = strings.TrimSpace(caption)
	description = strings.TrimSpace(description)
	// A data URI is not a caption. An image chunk can transiently hold the rendered
	// markdown form as its body (an import that has not yet had its bytes split out
	// into chunk_assets), and embedding base64 indexes nothing while consuming the
	// scope's vector quota.
	if typ, _, _, _ := classifyMediaBody(caption); typ == "image" {
		caption = ""
	}
	// A scaffold-only or letterless title is no more useful here than anywhere else.
	title = indexableText(title)

	// Appended rather than substituted, and deduped case-insensitively: each of the
	// three can be the one that matches a query, so dropping one for tidiness trades
	// recall away — but repeating the same words inflates their weight in the vector.
	parts := make([]string, 0, 3)
	add := func(v string) {
		if v == "" {
			return
		}
		for _, have := range parts {
			if strings.EqualFold(have, v) {
				return
			}
		}
		parts = append(parts, v)
	}
	add(title)
	add(caption)
	add(description)
	if len(parts) == 0 {
		return ""
	}
	// "image" is prefixed for the same reason the mermaid extractor names the
	// diagram kind: it is itself a useful query term when someone is looking for a
	// picture of something rather than prose about it.
	return "image: " + strings.Join(parts, " ")
}
