"""Durable, project-level facts extracted from user prompts (and later, discovery).

Codegen has always re-derived infrastructure/route decisions from prose on every run,
so a later, unrelated prompt could silently reset a user-named admin path back to the
"/admin" default, or forget that a contact form's submissions must stay readable in an
admin view. This module is the persisted, sticky record of those facts: once a real
value is recorded, nothing here is allowed to reset it back to a class default.

Deliberately deterministic (regex/keyword only, no LLM call) -- this runs on every
codegen request and must be cheap and predictable.
"""
from __future__ import annotations

import re
from typing import Literal, Optional

from pydantic import BaseModel, Field


class ProjectRuntimeInvariants(BaseModel):
    version: int = 1
    admin_panel_path: str = "/admin"
    customer_login_path: str = "/login"
    auth_modes: list[str] = Field(default_factory=list)
    persistence_required: bool = False
    admin_must_read_persisted: bool = False
    allow_custom_backend: bool = False
    email_preference: Optional[str] = None
    source: Literal["default", "prompt", "discovery"] = "default"


def validate_runtime_routes(invariants: ProjectRuntimeInvariants) -> list[str]:
    """Return structural route violations for a project's audience surfaces."""
    errors: list[str] = []
    if invariants.admin_panel_path == invariants.customer_login_path:
        errors.append("admin_panel_path_must_differ_from_customer_login_path")
    return errors


_ADMIN_KEYWORDS = re.compile(r"\b(?:admin|staff|dashboard|secure)\b", re.I)
_LOGIN_KEYWORDS = re.compile(r"\b(?:login|log[\s-]?in|sign[\s-]?in)\b", re.I)
# A generic "login" mention commonly describes protection for an admin/staff
# route (for example: "secure /admin panel with login authentication").  It
# must not make that staff route the customer login route.  A customer route
# is only explicit when the text names a customer/user/member/public login or
# puts the route next to a sign-in verb.
_CUSTOMER_LOGIN_KEYWORDS = re.compile(
    r"\b(?:customer|customers|user|users|member|members|public|client|clients)\b"
    r"[\s\S]{0,48}\b(?:login|log[\s-]?in|sign[\s-]?in)\b"
    r"|\b(?:login|log[\s-]?in|sign[\s-]?in)\b[\s\S]{0,48}"
    r"\b(?:customer|customers|user|users|member|members|public|client|clients)\b",
    re.I,
)
_PATH_TOKEN = re.compile(r"(?<![\w/.])/[A-Za-z][A-Za-z0-9._-]*(?:/[A-Za-z0-9._-]+)*/?")
_MAGIC_LINK_KEYWORDS = re.compile(r"\bmagic\s*link\b|\bpasswordless\b", re.I)
_PERSIST_KEYWORDS = re.compile(
    r"\b(?:store|stored|save|saved|submit|submitted|submission|submissions|"
    r"record|recorded|inbox|persist|persisted|persistence)\b",
    re.I,
)
_ADMIN_VIEW_KEYWORDS = re.compile(
    r"\b(?:admin|dashboard|staff)\b[\s\S]{0,40}\b(?:view|see|retrieve|read|list|manage)\b"
    r"|\b(?:view|see|retrieve|read|list|manage)\b[\s\S]{0,40}\b(?:admin|dashboard|staff)\b",
    re.I,
)
_EMAIL_PROVIDER_KEYWORDS: dict[str, re.Pattern] = {
    "resend": re.compile(r"\bresend\b", re.I),
    "mailgun": re.compile(r"\bmailgun\b", re.I),
    "sendgrid": re.compile(r"\bsendgrid\b", re.I),
    "postmark": re.compile(r"\bpostmark\b", re.I),
}
_PATH_SHAPE = re.compile(r"^/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+/?$")


def _safe_path(value: str) -> Optional[str]:
    """Port of Cloud's isSafeAppPath (app-origin.ts) -- same shape, same rejections."""
    # Path tokens are extracted from prose, so sentence punctuation can be
    # attached (``sign in at /signin.``).  Remove only trailing punctuation;
    # dots inside a path remain valid.
    value = (value or "").strip().rstrip(".,;:!?)]}")
    if not value:
        return None
    path = value if value.startswith("/") else f"/{value}"
    if len(path) > 200 or ".." in path or "//" in path or "\\" in path:
        return None
    if path == "/":
        return "/"
    if not _PATH_SHAPE.match(path):
        return None
    return path.rstrip("/") or "/"


def _find_named_path(text: str, keyword_pattern: re.Pattern, window: int = 60) -> Optional[str]:
    """First path token in `text` with a matching keyword within `window` chars either side."""
    for match in _PATH_TOKEN.finditer(text):
        start, end = match.span()
        context = text[max(0, start - window): end + window]
        if keyword_pattern.search(context):
            safe = _safe_path(match.group())
            if safe and safe != "/":
                return safe
    return None


def _find_customer_login_path(text: str) -> Optional[str]:
    """Find an explicitly named customer-facing login path.

    Do not infer this from a broad login/admin proximity window: discovery
    briefs routinely say "secure /admin panel with login authentication".
    That is an admin route requirement, not a customer login route.
    """
    for match in _PATH_TOKEN.finditer(text):
        start, end = match.span()
        context = text[max(0, start - 60): end + 60]
        safe = _safe_path(match.group())
        if not safe or safe == "/":
            continue
        path_name = safe.strip("/").lower().replace("-", "_")
        # A named login path is unambiguous even when the brief omits
        # "customer", e.g. "login page at /signin".
        path_is_login = any(token in path_name for token in ("login", "signin", "sign_in"))
        if path_is_login and _LOGIN_KEYWORDS.search(context):
            return safe
        if _CUSTOMER_LOGIN_KEYWORDS.search(context):
            return safe
    return None


def extract_runtime_invariants(
    prompt: str, previous: ProjectRuntimeInvariants
) -> ProjectRuntimeInvariants:
    """Merge new signal from `prompt` onto `previous`. Never resets a recorded value."""
    text = prompt or ""
    updated = previous.model_copy(deep=True)
    changed = False

    admin_path = _find_named_path(text, _ADMIN_KEYWORDS)
    if admin_path and admin_path != "/admin" and admin_path != updated.admin_panel_path:
        updated.admin_panel_path = admin_path
        changed = True

    login_path = _find_customer_login_path(text)
    if login_path and login_path != "/login" and login_path != updated.customer_login_path:
        updated.customer_login_path = login_path
        changed = True

    if _MAGIC_LINK_KEYWORDS.search(text) and "magic_link" not in updated.auth_modes:
        updated.auth_modes = [*updated.auth_modes, "magic_link"]
        changed = True

    if _PERSIST_KEYWORDS.search(text) and not updated.persistence_required:
        updated.persistence_required = True
        changed = True

    if (
        updated.persistence_required
        and _ADMIN_VIEW_KEYWORDS.search(text)
        and not updated.admin_must_read_persisted
    ):
        updated.admin_must_read_persisted = True
        changed = True

    if updated.email_preference is None:
        for name, pattern in _EMAIL_PROVIDER_KEYWORDS.items():
            if pattern.search(text):
                updated.email_preference = name
                changed = True
                break

    if changed and updated.source != "discovery":
        updated.source = "prompt"

    route_errors = validate_runtime_routes(updated)
    if route_errors:
        raise ValueError("invalid runtime routes: " + ", ".join(route_errors))

    return updated
