"""MerchantResolver — canonicalise raw merchant strings into stable slugs.

Receipts present merchant identity in many forms: ``AMZN MKTP US*8XK4N`` /
``Amazon.com`` / ``Amazon EU S.à r.l.`` / ``アマゾン`` all describe one entity.
This module is the resolution layer: given raw text, find the canonical slug,
or signal "no match — propose a new merchant for the user to approve".

Resolution tiers (cheapest first):

1. **Exact** — ``canonical_name`` case-insensitive match.
2. **Alias index** — case-insensitive match against any stored alias.
3. **Prefix heuristic** — strip POS noise (``*8XK4N``, ``#1234``, trailing
   amount suffixes) and re-run tiers 1+2.

No fuzzy matching, no embeddings, no auto-merge in Phase 1 — per the
session-107 entity-dedup memory, suggesting a merge for user approval is the
right default. Above-threshold pairs surface as proposals; the user clicks
merge or dismiss. Auto-classification of the middle band breaks trust.

Storage shares ``FinanceLedger``'s ``finance_merchants`` table — same
``aiosqlite.Connection``, separate lock. SQLite WAL + ``busy_timeout=5000``
handles cross-service serialisation; the per-service lock just coordinates
within-service multi-statement transactions.
"""

from __future__ import annotations

import asyncio
import json
import logging
import re
import unicodedata
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from agntspace.finance.types import Merchant

if TYPE_CHECKING:
    import aiosqlite

log = logging.getLogger(__name__)


# Common POS noise patterns. Conservative — strips suffixes that are obviously
# transaction-specific (auth codes, store numbers, dates) without touching the
# merchant identity. Tuned later in skill YAML if needed.
_POS_NOISE = [
    re.compile(r"\*[A-Z0-9]{4,}\b"),       # ``*8XK4N``, ``*123456`` auth codes
    re.compile(r"#\d{2,}\b"),              # ``#1234`` store numbers
    re.compile(r"\b\d{2}/\d{2}\b"),        # ``11/24`` partial dates
    re.compile(r"\bUS\*\b"),               # ``US*`` regional marker
    re.compile(r"\bMKTP\b", re.IGNORECASE), # marketplace marker
]


def _slugify(text: str) -> str:
    """ASCII slug for filesystem + URL safety.

    Diacritics → ASCII via NFKD ("Müller" → "muller"). Cyrillic / CJK fall
    through to alphanumeric stripping; the slug ends up empty and the caller
    must supply a fallback. Same convention as bibliography cite keys.
    """
    if not text:
        return ""
    normalised = unicodedata.normalize("NFKD", text.strip().lower())
    ascii_only = normalised.encode("ascii", "ignore").decode("ascii")
    cleaned = re.sub(r"[^a-z0-9]+", "-", ascii_only)
    return cleaned.strip("-")


def _normalize_alias(text: str) -> str:
    """Lowercase + collapse whitespace, but preserve script.

    Used for alias-index lookups. Keeps Cyrillic/CJK text intact so an
    ``Amazon``/``アマゾン`` alias still matches when re-observed.
    """
    return " ".join(str(text or "").strip().lower().split())


def _strip_pos_noise(text: str) -> str:
    """Remove transaction-specific suffixes from a merchant string."""
    cleaned = text
    for pattern in _POS_NOISE:
        cleaned = pattern.sub("", cleaned)
    cleaned = re.sub(r"\s+", " ", cleaned).strip()
    return cleaned.strip("-_*. ")


def _now_iso_date() -> str:
    return datetime.now(UTC).date().isoformat()


class MerchantResolver:
    """Resolves raw merchant strings to canonical merchant records.

    Lifecycle:
        resolver = MerchantResolver(db)
        await resolver.initialize()
        match = await resolver.resolve("AMZN MKTP US*8XK4N")
        if match is None:
            slug = await resolver.upsert_merchant(
                slug="", canonical_name="Amazon", aliases=["AMZN MKTP US*8XK4N"]
            )

    The resolver NEVER guesses a slug for a brand-new merchant — that's the
    bookkeeper skill's job (it has the LLM context and the user-approval gate).
    Resolver returns ``None`` on miss so the caller can drive the right flow.
    """

    def __init__(self, db: aiosqlite.Connection | None = None) -> None:
        self._db = db
        self._lock = asyncio.Lock()
        self._initialized = False
        # In-memory fallback for tests
        self._mem: dict[str, dict] = {}
        # Alias → slug lookup (lowercased). Rebuilt on initialize() and
        # maintained on upsert_merchant / add_alias.
        self._alias_index: dict[str, str] = {}

    async def initialize(self) -> None:
        """Build the alias index from the existing merchants table.

        Schema is owned by ``FinanceLedger._SCHEMA``; we just populate the
        index. Safe to call multiple times.
        """
        if self._initialized:
            return
        if self._db is None:
            self._initialized = True
            return
        async with self._lock:
            try:
                cur = await self._db.execute(
                    "SELECT merchant_slug, canonical_name, aliases FROM finance_merchants"
                )
                rows = await cur.fetchall()
                await cur.close()
                for slug, canonical_name, aliases_json in rows:
                    self._alias_index[_normalize_alias(canonical_name)] = slug
                    try:
                        for alias in json.loads(aliases_json or "[]"):
                            key = _normalize_alias(alias)
                            if key:
                                self._alias_index[key] = slug
                    except (json.JSONDecodeError, TypeError):
                        continue
                self._initialized = True
            except Exception:
                log.exception("Failed to initialize merchant resolver")

    # ────────────────────────────────────────────────────────────────────
    # Resolution
    # ────────────────────────────────────────────────────────────────────
    async def resolve(self, raw_text: str) -> Merchant | None:
        """Return the canonical merchant for a raw string, or None on miss.

        Tiers:
            1. exact canonical/alias index lookup (case-insensitive)
            2. POS-noise-stripped re-lookup
        Returns ``None`` if no tier hits — the caller decides whether to
        create a new merchant (with user approval) or skip.
        """
        if not self._initialized:
            await self.initialize()
        text = (raw_text or "").strip()
        if not text:
            return None

        # Tier 1 — exact alias / canonical match
        slug = self._alias_index.get(_normalize_alias(text))
        if slug:
            return await self.get(slug)

        # Tier 2 — strip POS noise and retry
        stripped = _strip_pos_noise(text)
        if stripped and stripped != text:
            slug = self._alias_index.get(_normalize_alias(stripped))
            if slug:
                return await self.get(slug)

        return None

    async def get(self, merchant_slug: str) -> Merchant | None:
        """Fetch one merchant by slug."""
        if not self._initialized:
            await self.initialize()
        if not merchant_slug:
            return None
        if self._db is None:
            row = self._mem.get(merchant_slug)
            return _row_to_merchant(row) if row else None
        try:
            cur = await self._db.execute(
                "SELECT merchant_slug, canonical_name, aliases, country, "
                "default_category, notes, last_seen, tax_id "
                "FROM finance_merchants WHERE merchant_slug = ?",
                (merchant_slug,),
            )
            row = await cur.fetchone()
            await cur.close()
            if row is None:
                return None
            return _row_to_merchant(
                {
                    "merchant_slug": row[0],
                    "canonical_name": row[1],
                    "aliases": row[2],
                    "country": row[3],
                    "default_category": row[4],
                    "notes": row[5],
                    "last_seen": row[6],
                    "tax_id": row[7] if len(row) > 7 else "",
                }
            )
        except Exception:
            log.exception("Failed to fetch merchant %s", merchant_slug)
            return None

    async def list_merchants(self, *, limit: int = 200, offset: int = 0) -> list[Merchant]:
        if not self._initialized:
            await self.initialize()
        if self._db is None:
            rows = sorted(
                self._mem.values(),
                key=lambda r: r.get("last_seen", ""),
                reverse=True,
            )
            return [_row_to_merchant(r) for r in rows[offset : offset + limit]]
        try:
            cur = await self._db.execute(
                "SELECT merchant_slug, canonical_name, aliases, country, "
                "default_category, notes, last_seen, tax_id "
                "FROM finance_merchants "
                "ORDER BY last_seen DESC, canonical_name ASC "
                "LIMIT ? OFFSET ?",
                (limit, offset),
            )
            rows = await cur.fetchall()
            await cur.close()
            return [
                _row_to_merchant({
                    "merchant_slug": r[0],
                    "canonical_name": r[1],
                    "aliases": r[2],
                    "country": r[3],
                    "default_category": r[4],
                    "notes": r[5],
                    "last_seen": r[6],
                    "tax_id": r[7] if len(r) > 7 else "",
                })
                for r in rows
            ]
        except Exception:
            log.exception("Failed to list merchants")
            return []

    # ────────────────────────────────────────────────────────────────────
    # Mutations
    # ────────────────────────────────────────────────────────────────────
    async def upsert_merchant(
        self,
        *,
        slug: str = "",
        canonical_name: str,
        aliases: list[str] | None = None,
        country: str = "",
        default_category: str = "",
        notes: str = "",
        tax_id: str = "",
    ) -> str:
        """Create or update a merchant. Returns the slug.

        If ``slug`` is empty, derive from ``canonical_name`` via _slugify;
        if the derived slug is empty (non-Latin script) or already taken
        by a DIFFERENT canonical name, append a short hash for uniqueness.

        ``tax_id`` (Phase 1.5) — when provided, persisted on the record so
        future receipts can cross-validate merchant identity. Empty input
        does NOT clear an existing tax_id (idempotent observation pattern).
        """
        if not self._initialized:
            await self.initialize()
        if not canonical_name.strip():
            return ""

        # Derive slug if not provided
        target_slug = slug or _slugify(canonical_name)
        if not target_slug:
            # Non-Latin canonical name → use a stable hash of the name
            import hashlib
            digest = hashlib.sha256(canonical_name.encode("utf-8")).hexdigest()[:8]
            target_slug = f"merchant-{digest}"

        existing = await self.get(target_slug)
        if existing and existing.canonical_name.lower() != canonical_name.strip().lower():
            # Slug collision with a different merchant — disambiguate
            import hashlib
            digest = hashlib.sha256(canonical_name.encode("utf-8")).hexdigest()[:6]
            target_slug = f"{target_slug}-{digest}"

        merged_aliases: list[str] = []
        seen: set[str] = set()
        for alias in (existing.aliases if existing else ()) + tuple(aliases or ()):
            key = _normalize_alias(alias)
            if key and key not in seen and key != _normalize_alias(canonical_name):
                seen.add(key)
                merged_aliases.append(alias.strip())

        last_seen = existing.last_seen if existing else _now_iso_date()
        record = {
            "merchant_slug": target_slug,
            "canonical_name": canonical_name.strip(),
            "aliases": json.dumps(merged_aliases, ensure_ascii=False),
            "country": (country or (existing.country if existing else "")).upper()[:2],
            "default_category": default_category or (existing.default_category if existing else ""),
            "notes": notes or (existing.notes if existing else ""),
            "last_seen": last_seen,
            "tax_id": tax_id.strip() or (existing.tax_id if existing else ""),
        }

        if self._db is None:
            self._mem[target_slug] = record
        else:
            try:
                async with self._lock:
                    await self._db.execute(
                        "INSERT INTO finance_merchants ("
                        "merchant_slug, canonical_name, aliases, country, "
                        "default_category, notes, last_seen, tax_id"
                        ") VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
                        "ON CONFLICT(merchant_slug) DO UPDATE SET "
                        "canonical_name = excluded.canonical_name, "
                        "aliases = excluded.aliases, "
                        "country = excluded.country, "
                        "default_category = excluded.default_category, "
                        "notes = excluded.notes, "
                        "tax_id = CASE "
                        "  WHEN excluded.tax_id != '' THEN excluded.tax_id "
                        "  ELSE finance_merchants.tax_id "
                        "END",
                        (
                            record["merchant_slug"],
                            record["canonical_name"],
                            record["aliases"],
                            record["country"],
                            record["default_category"],
                            record["notes"],
                            record["last_seen"],
                            record["tax_id"],
                        ),
                    )
                    await self._db.commit()
            except Exception:
                log.exception("Failed to upsert merchant %s", target_slug)
                return ""

        # Refresh alias index
        self._alias_index[_normalize_alias(canonical_name)] = target_slug
        for alias in merged_aliases:
            key = _normalize_alias(alias)
            if key:
                self._alias_index[key] = target_slug
        return target_slug

    async def add_alias(self, merchant_slug: str, alias: str) -> bool:
        """Append a newly-observed alias. Idempotent.

        Called from the receipt-ingest pipeline when a transaction's
        ``merchant_raw`` differs from any known alias for the resolved
        merchant — captures spelling variants without user prompting.
        """
        if not self._initialized:
            await self.initialize()
        normalised = _normalize_alias(alias)
        if not normalised or self._alias_index.get(normalised) == merchant_slug:
            return False
        existing = await self.get(merchant_slug)
        if existing is None:
            return False
        new_aliases = list(existing.aliases) + [alias.strip()]
        await self.upsert_merchant(
            slug=merchant_slug,
            canonical_name=existing.canonical_name,
            aliases=new_aliases,
            country=existing.country,
            default_category=existing.default_category,
            notes=existing.notes,
        )
        return True

    async def touch_last_seen(self, merchant_slug: str, txn_date: str) -> None:
        """Bump last_seen if the new date is more recent. Cheap; called per commit."""
        if not self._initialized:
            await self.initialize()
        existing = await self.get(merchant_slug)
        if existing is None or not txn_date:
            return
        if existing.last_seen and txn_date <= existing.last_seen:
            return
        if self._db is None:
            row = self._mem.get(merchant_slug)
            if row is not None:
                row["last_seen"] = txn_date
            return
        try:
            async with self._lock:
                await self._db.execute(
                    "UPDATE finance_merchants SET last_seen = ? WHERE merchant_slug = ?",
                    (txn_date, merchant_slug),
                )
                await self._db.commit()
        except Exception:
            log.exception("Failed to touch last_seen for %s", merchant_slug)


def _row_to_merchant(row: dict | None) -> Merchant | None:
    if row is None:
        return None
    try:
        aliases = tuple(json.loads(row.get("aliases") or "[]"))
    except (json.JSONDecodeError, TypeError):
        aliases = ()
    return Merchant(
        merchant_slug=row.get("merchant_slug", ""),
        canonical_name=row.get("canonical_name", ""),
        aliases=aliases,
        country=row.get("country", "") or "",
        default_category=row.get("default_category", "") or "",
        notes=row.get("notes", "") or "",
        last_seen=row.get("last_seen", "") or "",
        tax_id=row.get("tax_id", "") or "",
    )


__all__ = ["MerchantResolver", "_slugify", "_strip_pos_noise"]
