import { z } from "zod";
import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
import { SortDir } from "@open-mercato/shared/lib/query/types";
import { E } from "../../../generated/entities.ids.generated.js";
import { Attachment } from "@open-mercato/core/modules/attachments/data/entities";
import {
  CatalogProduct,
  CatalogProductCategory,
  CatalogProductCategoryAssignment,
  CatalogProductPrice,
  CatalogProductTag,
  CatalogProductTagAssignment
} from "../data/entities.js";
import { assertTenantScope } from "./types.js";
import {
  buildProductBundle,
  buildScope,
  listPriceKindsCore,
  resolveAttributeSchema,
  resolveEm,
  toPriceNumeric,
  toProductSummary
} from "./_shared.js";
import { createLogger } from "@open-mercato/shared/lib/logger";
const logger = createLogger("catalog");
const CATALOG_PRODUCT_ENTITY = "catalog:catalog_product";
function resolveSearchService(ctx) {
  try {
    return ctx.container.resolve("searchService");
  } catch {
    return null;
  }
}
const searchProductsInput = z.object({
  q: z.string().trim().optional().describe('Optional fulltext query (title / subtitle / sku / handle). Omit or leave empty (or do NOT pass it at all) to list all products. NEVER use "*", "**", or "%" \u2014 they are not wildcards and will be discarded.'),
  limit: z.number().int().min(1).max(100).optional().describe("Page size. Default 50, hard maximum 100. Results are paginated: when `total` exceeds `limit + offset`, call again with the next `offset` instead of asking for more than 100 rows."),
  offset: z.number().int().min(0).optional().describe("Rows to skip for pagination (default 0). Combine with `limit` to fetch subsequent pages \u2014 for example offset=100, limit=100 fetches rows 101..200."),
  categoryId: z.string().optional().describe("Restrict to products assigned to this catalog category UUID. Only use a category ID returned by a previous tool call \u2014 do NOT guess. Empty string is treated the same as omitting the field."),
  priceMin: z.number().optional().describe('Lower-bound (inclusive) on the gross unit price. OMIT this field when you do not want a lower bound \u2014 do NOT pass 0 as "no minimum", because 0 is a real bound that combined with priceMax=0 will return only free products.'),
  priceMax: z.number().optional().describe('Upper-bound (inclusive) on the gross unit price. OMIT this field when you do not want an upper bound \u2014 do NOT pass 0 as "no maximum", because 0 is a real bound that will exclude every priced product.'),
  tags: z.array(z.string()).optional().describe("Tag labels or slugs (any-match) the product carries. Omit or pass an empty array to skip the tag filter."),
  active: z.boolean().optional().describe("When true, only active products are returned. Omit to include inactive products as well.")
}).passthrough();
async function queryProductsWithFilters(em, ctx, tenantId, input, restrictToIds) {
  const limit = input.limit ?? 50;
  const offset = input.offset ?? 0;
  let idRestriction = restrictToIds ? Array.from(new Set(restrictToIds)) : null;
  if (input.categoryId) {
    const assignments = await findWithDecryption(
      em,
      CatalogProductCategoryAssignment,
      { tenantId, category: input.categoryId },
      void 0,
      buildScope(ctx, tenantId)
    );
    const ids = assignments.map((assignment) => {
      const product = assignment.product;
      if (!product) return null;
      return typeof product === "string" ? product : product.id ?? null;
    }).filter((value) => typeof value === "string" && value.length > 0);
    if (!ids.length) return { items: [], total: 0 };
    idRestriction = idRestriction ? idRestriction.filter((id) => ids.includes(id)) : ids;
    if (!idRestriction.length) return { items: [], total: 0 };
  }
  if (input.tags && input.tags.length > 0) {
    const tagWhere = {
      tenantId,
      $or: [
        { slug: { $in: input.tags } },
        { label: { $in: input.tags } }
      ]
    };
    if (ctx.organizationId) tagWhere.organizationId = ctx.organizationId;
    const tags = await findWithDecryption(
      em,
      CatalogProductTag,
      tagWhere,
      void 0,
      buildScope(ctx, tenantId)
    );
    const tagIds = tags.map((tag) => tag.id);
    if (!tagIds.length) return { items: [], total: 0 };
    const assignments = await findWithDecryption(
      em,
      CatalogProductTagAssignment,
      { tenantId, tag: { $in: tagIds } },
      void 0,
      buildScope(ctx, tenantId)
    );
    const scopedIds = assignments.map((assignment) => {
      const product = assignment.product;
      if (!product) return null;
      return typeof product === "string" ? product : product.id ?? null;
    }).filter((value) => typeof value === "string" && value.length > 0);
    if (!scopedIds.length) return { items: [], total: 0 };
    idRestriction = idRestriction ? idRestriction.filter((id) => scopedIds.includes(id)) : scopedIds;
    if (!idRestriction.length) return { items: [], total: 0 };
  }
  if (input.priceMin !== void 0 || input.priceMax !== void 0) {
    const priceWhere = { tenantId };
    if (ctx.organizationId) priceWhere.organizationId = ctx.organizationId;
    const priceRows = await findWithDecryption(
      em,
      CatalogProductPrice,
      priceWhere,
      void 0,
      buildScope(ctx, tenantId)
    );
    const bounded = priceRows.filter((row) => {
      const net = toPriceNumeric(row.unitPriceNet ?? null);
      const gross = toPriceNumeric(row.unitPriceGross ?? null);
      const comparable = gross ?? net;
      if (comparable === null) return false;
      if (input.priceMin !== void 0 && comparable < input.priceMin) return false;
      if (input.priceMax !== void 0 && comparable > input.priceMax) return false;
      return true;
    });
    const scopedIds = bounded.map((row) => {
      const product = row.product;
      if (!product) return null;
      return typeof product === "string" ? product : product.id ?? null;
    }).filter((value) => typeof value === "string" && value.length > 0);
    if (!scopedIds.length) return { items: [], total: 0 };
    idRestriction = idRestriction ? idRestriction.filter((id) => scopedIds.includes(id)) : Array.from(new Set(scopedIds));
    if (!idRestriction.length) return { items: [], total: 0 };
  }
  const filters = {};
  if (input.active === true) filters.is_active = true;
  if (input.q) {
    const pattern = `%${input.q}%`;
    filters.$or = [
      { title: { $ilike: pattern } },
      { subtitle: { $ilike: pattern } },
      { sku: { $ilike: pattern } },
      { handle: { $ilike: pattern } }
    ];
  }
  if (idRestriction) {
    filters.id = { $in: idRestriction };
  }
  const qe = ctx.container.resolve("queryEngine");
  const result = await qe.query("catalog:catalog_product", {
    filters,
    sort: [{ field: "created_at", dir: SortDir.Desc }],
    page: { page: Math.floor(offset / limit) + 1, pageSize: limit },
    tenantId,
    organizationId: ctx.organizationId ?? void 0
  });
  const productIds = result.items.map((row) => row.id).filter((id) => typeof id === "string");
  if (!productIds.length) return { items: [], total: result.total };
  const products = await findWithDecryption(
    em,
    CatalogProduct,
    { tenantId, id: { $in: productIds }, deletedAt: null },
    void 0,
    buildScope(ctx, tenantId)
  );
  const productById = new Map(products.map((p) => [p.id, p]));
  const ordered = productIds.map((id) => productById.get(id)).filter((p) => !!p);
  return { items: ordered.map(toProductSummary), total: result.total };
}
const searchProductsTool = {
  name: "catalog.search_products",
  displayName: "Search products",
  description: 'Hybrid search + filter across tenant products. When `q` is non-empty, routes through the search service (tenant + organization scoped) then hydrates tenant-scoped product summaries; when `q` is empty or omitted, runs the catalog query engine with the supplied filters and returns ALL products. Pagination: response shape is `{ items, total, limit, offset, source }`. Hard cap is 100 rows per call (`limit` max=100, default=50); if `total` exceeds `limit + offset`, call again with the next `offset` rather than asking for >100. Empty / sentinel inputs: pass an empty `q` (or omit it) to list all products. NEVER pass `0` for `priceMin`/`priceMax` to mean "no bound" \u2014 `0` is a real numeric bound (priceMin=0 + priceMax=0 returns only free products). To remove a bound, OMIT the field. Empty string `categoryId` and empty `tags` arrays are ignored. `source` in the output indicates which path executed (`search_service` vs `query_engine`).',
  inputSchema: searchProductsInput,
  requiredFeatures: ["catalog.products.view"],
  tags: ["read", "catalog", "merchandising"],
  isMutation: false,
  handler: async (rawInput, ctx) => {
    const { tenantId } = assertTenantScope(ctx);
    const input = searchProductsInput.parse(rawInput);
    const em = resolveEm(ctx);
    const limit = input.limit ?? 50;
    const offset = input.offset ?? 0;
    if (!input.q || input.q === "*" || input.q === "**" || input.q === "%") {
      input.q = void 0;
    }
    if (typeof input.categoryId === "string" && input.categoryId.trim() === "") {
      input.categoryId = void 0;
    }
    if (Array.isArray(input.tags) && input.tags.length === 0) {
      input.tags = void 0;
    }
    if (input.priceMin === 0 && input.priceMax === 0) {
      input.priceMin = void 0;
      input.priceMax = void 0;
    }
    if (input.categoryId) {
      const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
      if (!uuidPattern.test(input.categoryId)) {
        input.categoryId = void 0;
      } else {
        try {
          const catExists = await em.count(CatalogProductCategory, { id: input.categoryId, tenantId });
          if (catExists === 0) input.categoryId = void 0;
        } catch {
          input.categoryId = void 0;
        }
      }
    }
    if (input.q && input.q.trim().length > 0) {
      const service = resolveSearchService(ctx);
      if (service) {
        const hits = await service.search(input.q.trim(), {
          tenantId,
          organizationId: ctx.organizationId,
          limit,
          entityTypes: [CATALOG_PRODUCT_ENTITY]
        });
        const hitIds = hits.filter((hit) => hit.entityId === CATALOG_PRODUCT_ENTITY).map((hit) => hit.recordId).filter((id) => typeof id === "string" && id.length > 0);
        if (!hitIds.length) {
          return { items: [], total: 0, limit, offset, source: "search_service" };
        }
        const { items: items2, total: total2 } = await queryProductsWithFilters(em, ctx, tenantId, { ...input, q: void 0 }, hitIds);
        return { items: items2, total: total2, limit, offset, source: "search_service" };
      }
    }
    const { items, total } = await queryProductsWithFilters(em, ctx, tenantId, input, null);
    return { items, total, limit, offset, source: "query_engine" };
  }
};
const getProductBundleInput = z.object({
  productId: z.string().uuid().describe("Catalog product id (UUID).")
});
const getProductBundleTool = {
  name: "catalog.get_product_bundle",
  displayName: "Get product bundle",
  description: "Aggregate product snapshot for D18 merchandising: core fields + categories + tags + variants + prices (base + best via pricing service) + media metadata + custom-field values + merged attribute schema. Media bytes flow through the attachment bridge (use `catalog.get_product_media` then the bridge). Returns `{ found: false }` on miss or cross-tenant access.",
  inputSchema: getProductBundleInput,
  requiredFeatures: ["catalog.products.view"],
  tags: ["read", "catalog", "merchandising"],
  isMutation: false,
  handler: async (rawInput, ctx) => {
    const { tenantId } = assertTenantScope(ctx);
    const input = getProductBundleInput.parse(rawInput);
    const em = resolveEm(ctx);
    return buildProductBundle(em, ctx, tenantId, input.productId);
  }
};
const listSelectedProductsInput = z.object({
  productIds: z.array(z.string().uuid()).min(1).max(50).describe("1..50 catalog product ids (UUIDs). Duplicates are collapsed; cross-tenant ids drop into `missingIds`.")
});
const listSelectedProductsTool = {
  name: "catalog.list_selected_products",
  displayName: "List selected products (bundles)",
  description: "Bulk variant of `catalog.get_product_bundle`: resolves 1..50 product ids into tenant-scoped bundle aggregates. Missing / cross-tenant ids are returned in `missingIds` (not as an error) so selection-aware agents can render partial results.",
  inputSchema: listSelectedProductsInput,
  requiredFeatures: ["catalog.products.view"],
  tags: ["read", "catalog", "merchandising"],
  isMutation: false,
  handler: async (rawInput, ctx) => {
    const { tenantId } = assertTenantScope(ctx);
    const input = listSelectedProductsInput.parse(rawInput);
    const em = resolveEm(ctx);
    const unique = Array.from(new Set(input.productIds));
    const resolved = await Promise.all(
      unique.map(async (productId) => ({ productId, result: await buildProductBundle(em, ctx, tenantId, productId) }))
    );
    const items = [];
    const missingIds = [];
    for (const entry of resolved) {
      if (entry.result.found) {
        items.push(entry.result);
      } else {
        missingIds.push(entry.productId);
        logger.warn("catalog.list_selected_products product not in scope", { productId: entry.productId });
      }
    }
    return { items, missingIds };
  }
};
const getProductMediaInput = z.object({
  productId: z.string().uuid().describe("Catalog product id (UUID)."),
  limit: z.number().int().min(1).max(100).optional().describe("Max rows (default 50, max 100)."),
  offset: z.number().int().min(0).optional().describe("Rows to skip (default 0).")
});
const getProductMediaTool = {
  name: "catalog.get_product_media",
  displayName: "Get product media (with attachment IDs)",
  description: "List media records attached to a product with metadata (filename, mime, size, sort order) and the `attachmentId` string for each row. Does NOT invoke the attachment bridge \u2014 the Step 3.7 runtime bridge converts attachment ids into model file parts when the chat/object helper invokes this tool in-context. Returns `{ items, total, limit, offset }`.",
  inputSchema: getProductMediaInput,
  requiredFeatures: ["catalog.products.view"],
  tags: ["read", "catalog", "merchandising"],
  isMutation: false,
  handler: async (rawInput, ctx) => {
    const { tenantId } = assertTenantScope(ctx);
    const input = getProductMediaInput.parse(rawInput);
    const em = resolveEm(ctx);
    const limit = input.limit ?? 50;
    const offset = input.offset ?? 0;
    const where = {
      tenantId,
      entityId: E.catalog.catalog_product,
      recordId: input.productId
    };
    if (ctx.organizationId) where.organizationId = ctx.organizationId;
    const [rows, total] = await Promise.all([
      findWithDecryption(
        em,
        Attachment,
        where,
        { limit, offset, orderBy: { createdAt: "asc" } },
        buildScope(ctx, tenantId)
      ),
      em.count(Attachment, where)
    ]);
    const filtered = rows.filter((row) => (row.tenantId ?? null) === tenantId);
    return {
      items: filtered.map((row) => ({
        mediaId: row.id,
        productId: input.productId,
        attachmentId: row.id,
        fileName: row.fileName,
        mediaType: row.mimeType,
        size: row.fileSize,
        altText: null,
        sortOrder: 0
      })),
      total,
      limit,
      offset
    };
  }
};
const getAttributeSchemaInput = z.object({
  productId: z.string().uuid().optional().describe("Narrow schema resolution to this product (scope: `product`)."),
  categoryId: z.string().uuid().optional().describe("Narrow schema resolution to this category (scope: `category`).")
});
const getAttributeSchemaTool = {
  name: "catalog.get_attribute_schema",
  displayName: "Get attribute schema",
  description: "Resolve the merged custom-field attribute schema for catalog products. When both `productId` and `categoryId` are absent, returns module-level fields only. Reuses the shared `loadCustomFieldDefinitionIndex` resolver so tenant + organization scoping rules stay consistent with CrudForm / admin routes.",
  inputSchema: getAttributeSchemaInput,
  requiredFeatures: ["catalog.products.view"],
  tags: ["read", "catalog", "merchandising"],
  isMutation: false,
  handler: async (rawInput, ctx) => {
    const { tenantId } = assertTenantScope(ctx);
    const input = getAttributeSchemaInput.parse(rawInput);
    return resolveAttributeSchema(ctx, tenantId, input.productId, input.categoryId);
  }
};
const getCategoryBriefInput = z.object({
  categoryId: z.string().uuid().describe("Category id (UUID).")
});
const getCategoryBriefTool = {
  name: "catalog.get_category_brief",
  displayName: "Get category brief",
  description: "Category name, full tree path, description, and merged attribute schema (same resolver as `catalog.get_attribute_schema` with `categoryId`). Returns `{ found: false }` on miss / cross-tenant.",
  inputSchema: getCategoryBriefInput,
  requiredFeatures: ["catalog.categories.view"],
  tags: ["read", "catalog", "merchandising"],
  isMutation: false,
  handler: async (rawInput, ctx) => {
    const { tenantId } = assertTenantScope(ctx);
    const input = getCategoryBriefInput.parse(rawInput);
    const em = resolveEm(ctx);
    const where = {
      id: input.categoryId,
      tenantId,
      deletedAt: null
    };
    if (ctx.organizationId) where.organizationId = ctx.organizationId;
    const category = await findOneWithDecryption(
      em,
      CatalogProductCategory,
      where,
      void 0,
      buildScope(ctx, tenantId)
    );
    if (!category || category.tenantId !== tenantId) {
      return { found: false, categoryId: input.categoryId };
    }
    const attributeSchema = await resolveAttributeSchema(ctx, tenantId, void 0, category.id);
    return {
      found: true,
      id: category.id,
      name: category.name,
      path: category.treePath ?? null,
      description: category.description ?? null,
      attributeSchema
    };
  }
};
const listPriceKindsInput = z.object({
  limit: z.number().int().min(1).max(100).optional().describe("Max rows (default 50, max 100)."),
  offset: z.number().int().min(0).optional().describe("Rows to skip (default 0).")
}).passthrough();
const listPriceKindsTool = {
  name: "catalog.list_price_kinds",
  displayName: "List price kinds",
  description: "Enumerate tenant price kinds for the D18 merchandising assistant. Shares the tenant-scoped query path with `catalog.list_price_kinds_base`; the two tools differ only in description/framing (the base tool is the low-level settings enumerator, this one is the spec-named D18 surface).",
  inputSchema: listPriceKindsInput,
  requiredFeatures: ["catalog.settings.manage"],
  tags: ["read", "catalog", "merchandising"],
  isMutation: false,
  handler: async (rawInput, ctx) => {
    const { tenantId } = assertTenantScope(ctx);
    const input = listPriceKindsInput.parse(rawInput);
    const base = await listPriceKindsCore(ctx, input, tenantId);
    return {
      items: base.items.map((row) => ({
        id: row.id,
        code: row.code,
        name: row.title,
        scope: row.organizationId ? "organization" : "tenant",
        currency: row.currencyCode,
        appliesTo: row.isPromotion ? "promotion" : "regular"
      })),
      total: base.total,
      limit: base.limit,
      offset: base.offset
    };
  }
};
const merchandisingAiTools = [
  searchProductsTool,
  getProductBundleTool,
  listSelectedProductsTool,
  getProductMediaTool,
  getAttributeSchemaTool,
  getCategoryBriefTool,
  listPriceKindsTool
];
var merchandising_pack_default = merchandisingAiTools;
export {
  merchandising_pack_default as default,
  merchandisingAiTools
};
//# sourceMappingURL=merchandising-pack.js.map
