"""PostgreSQL DDL parsing helpers for ``PostgreSQLAnalyzer``.

Internal module containing compiled regex patterns, URI builders,
SQL comment stripping, and table-body parsing functions used by
:class:`~.postgresql_analyzer.PostgreSQLAnalyzer`.

Not part of the public API -- do not import directly.
"""

from __future__ import annotations

import logging
import re

from cleveragents.domain.models.acms._sql_string_aware import (
    find_unquoted_semicolon as find_unquoted_semicolon,
)
from cleveragents.domain.models.acms._sql_string_aware import (
    strip_sql_comments as strip_sql_comments,
)
from cleveragents.domain.models.acms.analyzers import UKOTriple

logger = logging.getLogger(__name__)

__all__: list[str] = []

# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------

# Identifier fragment: matches "quoted-ident" or unquoted_ident (incl. $).
# Each _IDENT usage creates TWO groups: (quoted, unquoted).
# Quoted branch allows escaped double-quotes ("") inside identifiers.
_IDENT = r'(?:"((?:[^"]|"")+)"|([\w$]+))'

# Dollar-quoting: $$...$$ or $tag$...$tag$ (PostgreSQL string literals).
_DOLLAR_RE = re.compile(r"\$(\w*)\$")


def ident_pair(match: re.Match[str], g1: int, g2: int) -> str | None:
    """Return the identifier captured by an ``_IDENT`` group pair.

    Each ``_IDENT`` in a regex produces *two* groups — one for the
    quoted branch and one for the unquoted branch.  Exactly one is
    non-``None``.  This helper collapses them into a single value and
    un-escapes doubled double-quotes (``""`` → ``"``).
    """
    quoted = match.group(g1)
    if quoted is not None:
        return quoted.replace('""', '"')
    return match.group(g2)


CREATE_TABLE_RE = re.compile(
    r"CREATE\s+"
    r"(?:(?:GLOBAL\s+|LOCAL\s+)?(?:TEMP(?:ORARY)?\s+)|UNLOGGED\s+)?"
    r"TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"
    rf"(?:{_IDENT}\.)?{_IDENT}\s*\(",
    re.IGNORECASE,
)

CREATE_VIEW_RE = re.compile(
    r"CREATE\s+"
    r"(?:OR\s+REPLACE\s+)?"
    r"(?:(?:TEMP(?:ORARY)?\s+)|(?:MATERIALIZED\s+))?"
    r"VIEW\s+"
    rf"(?:{_IDENT}\.)?{_IDENT}\s+AS\b",
    re.IGNORECASE,
)

CREATE_SCHEMA_RE = re.compile(
    rf"CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?{_IDENT}",
    re.IGNORECASE,
)

COLUMN_DEF_RE = re.compile(
    r'^\s*(?:"([^"]+)"|([\w$]+))\s+'
    r"((?:CHARACTER\s+VARYING|DOUBLE\s+PRECISION"
    r"|TIME(?:STAMP)?\s+WITH(?:OUT)?\s+TIME\s+ZONE"
    r"|[\w$]+)"
    r"(?:\s*\([^)]*\))?(?:\s*\[\s*\])?)",
    re.IGNORECASE,
)

NOT_NULL_RE = re.compile(r"\bNOT\s+NULL\b", re.IGNORECASE)

PRIMARY_KEY_INLINE_RE = re.compile(r"\bPRIMARY\s+KEY\b", re.IGNORECASE)

FOREIGN_KEY_RE = re.compile(
    r"FOREIGN\s+KEY\s*\(\s*([^)]+)\)\s*"
    rf"REFERENCES\s+(?:{_IDENT}\.)?{_IDENT}\s*\(\s*([^)]+)\)",
    re.IGNORECASE,
)

TABLE_CONSTRAINT_PK_RE = re.compile(
    r"PRIMARY\s+KEY\s*\(\s*([^)]+)\)",
    re.IGNORECASE,
)

# Keywords that start a line inside CREATE TABLE but are NOT column defs.
NON_COLUMN_KEYWORDS: frozenset[str] = frozenset(
    {
        "constraint",
        "primary",
        "foreign",
        "unique",
        "check",
        "exclude",
        "like",
        "inherits",
    }
)

# ---------------------------------------------------------------------------
# URI helpers
# ---------------------------------------------------------------------------

_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.]")


def safe_name(name: str) -> str:
    """Sanitise a SQL identifier for use in a URI path segment."""
    result = _SAFE_NAME_RE.sub("_", name)[:120]
    return result if result else "_unknown_"


def table_uri(resource_uri: str, table_name: str, schema_name: str = "") -> str:
    """Build a UKO table URI from a resource URI and table name."""
    prefix = f"{safe_name(schema_name)}." if schema_name else ""
    return f"uko://data/table/{safe_name(resource_uri)}/{prefix}{safe_name(table_name)}"


def column_uri(
    resource_uri: str,
    table_name: str,
    column_name: str,
    schema_name: str = "",
) -> str:
    """Build a UKO column URI."""
    prefix = f"{safe_name(schema_name)}." if schema_name else ""
    return (
        f"uko://data/column/{safe_name(resource_uri)}"
        f"/{prefix}{safe_name(table_name)}/{safe_name(column_name)}"
    )


def fk_uri(
    resource_uri: str,
    table_name: str,
    column_name: str,
    schema_name: str = "",
) -> str:
    """Build a UKO foreign-key URI."""
    prefix = f"{safe_name(schema_name)}." if schema_name else ""
    return (
        f"uko://data/fk/{safe_name(resource_uri)}"
        f"/{prefix}{safe_name(table_name)}/{safe_name(column_name)}"
    )


def view_uri(resource_uri: str, view_name: str, schema_name: str = "") -> str:
    """Build a UKO view URI."""
    prefix = f"{safe_name(schema_name)}." if schema_name else ""
    return f"uko://data/view/{safe_name(resource_uri)}/{prefix}{safe_name(view_name)}"


def schema_uri(resource_uri: str, schema_name: str) -> str:
    """Build a UKO schema URI."""
    return f"uko://data/schema/{safe_name(resource_uri)}/{safe_name(schema_name)}"


# ---------------------------------------------------------------------------
# Parenthesis / body extraction
# ---------------------------------------------------------------------------


def _skip_quoted(text: str, i: int) -> int:
    """Advance past a quoted region starting at *i*.

    Handles single-quoted strings (``''`` escape), double-quoted
    identifiers (``""`` escape), and dollar-quoted strings.
    Returns the index just past the closing delimiter, or ``len(text)``
    if the closing delimiter is never found.
    """
    ch = text[i]
    n = len(text)
    if ch in ("'", '"'):
        j = i + 1
        while j < n:
            if text[j] == ch:
                if j + 1 < n and text[j + 1] == ch:
                    j += 2
                else:
                    return j + 1
            else:
                j += 1
        return n
    if ch == "$":
        m = _DOLLAR_RE.match(text, i)
        if m:
            tag: str = m.group(0)
            end = text.find(tag, m.end())
            return (end + len(tag)) if end != -1 else n
    return i + 1


def extract_body(content: str, paren_start: int) -> str:
    """Return text between balanced parentheses starting at *paren_start*.

    Respects single-quoted strings, double-quoted identifiers, and
    dollar-quoted strings so that parentheses inside them do not
    affect the depth counter.
    """
    if paren_start >= len(content) or content[paren_start] != "(":
        return ""
    depth = 0
    i = paren_start
    while i < len(content):
        ch = content[i]
        if ch in ("'", '"') or (ch == "$" and _DOLLAR_RE.match(content, i)):
            i = _skip_quoted(content, i)
            continue
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
            if depth == 0:
                return content[paren_start + 1 : i]
        i += 1
    return ""


def split_entries(body: str) -> list[str]:
    """Split a table body into entries by commas at depth 0.

    Respects single-quoted strings, double-quoted identifiers, and
    dollar-quoted strings so that commas and parentheses inside them
    are ignored.
    """
    entries: list[str] = []
    depth = 0
    current: list[str] = []
    i = 0
    while i < len(body):
        ch = body[i]
        if ch in ("'", '"') or (ch == "$" and _DOLLAR_RE.match(body, i)):
            start = i
            i = _skip_quoted(body, i)
            current.append(body[start:i])
            continue
        if ch == "(":
            depth += 1
            current.append(ch)
        elif ch == ")":
            depth -= 1
            current.append(ch)
        elif ch == "," and depth == 0:
            entries.append("".join(current))
            current = []
        else:
            current.append(ch)
        i += 1
    tail = "".join(current).strip()
    if tail:
        entries.append(tail)
    return entries


# ---------------------------------------------------------------------------
# Column emission
# ---------------------------------------------------------------------------


def emit_column_triples(
    resource_uri: str,
    table_name: str,
    table_uri_val: str,
    col_name: str,
    col_type: str,
    *,
    schema_name: str = "",
    is_nullable: bool,
    is_primary_key: bool,
) -> list[UKOTriple]:
    """Produce triples for a single column definition."""
    triples: list[UKOTriple] = []
    c_uri = column_uri(resource_uri, table_name, col_name, schema_name)

    triples.append(
        UKOTriple(
            subject_uri=c_uri,
            predicate="rdf:type",
            object_uri="uko-data:Column",
        )
    )
    triples.append(
        UKOTriple(
            subject_uri=c_uri,
            predicate="rdfs:label",
            object_value=col_name,
        )
    )
    triples.append(
        UKOTriple(
            subject_uri=table_uri_val,
            predicate="uko:contains",
            object_uri=c_uri,
        )
    )
    triples.append(
        UKOTriple(
            subject_uri=c_uri,
            predicate="uko-data:dataType",
            object_value=col_type,
        )
    )
    triples.append(
        UKOTriple(
            subject_uri=c_uri,
            predicate="uko-data:isNullable",
            object_value=str(is_nullable).lower(),
        )
    )
    triples.append(
        UKOTriple(
            subject_uri=c_uri,
            predicate="uko-data:isPrimaryKey",
            object_value=str(is_primary_key).lower(),
        )
    )
    return triples


# ---------------------------------------------------------------------------
# Foreign key extraction
# ---------------------------------------------------------------------------


def extract_fk_triples(
    fk_match: re.Match[str],
    resource_uri: str,
    table_name: str,
    table_uri_val: str,
    schema_name: str = "",
) -> list[UKOTriple]:
    """Extract triples for a ``FOREIGN KEY`` constraint.

    Per spec section 42214-42217, ``uko-data:foreignKeyTo`` links
    Column -> Column (domain = uko-data:Column, range = uko-data:Column).

    Supports multi-column foreign keys: each source/target column pair
    gets its own ``foreignKeyTo`` edge.
    """
    triples: list[UKOTriple] = []

    source_cols = [
        c.strip().strip('"') for c in fk_match.group(1).split(",") if c.strip()
    ]
    ref_schema = ident_pair(fk_match, 2, 3) or schema_name
    ref_table = ident_pair(fk_match, 4, 5) or ""
    target_cols = [
        c.strip().strip('"') for c in fk_match.group(6).split(",") if c.strip()
    ]

    first_col = source_cols[0] if source_cols else "_unknown_"
    fk_uri_val = fk_uri(resource_uri, table_name, first_col, schema_name)

    triples.append(
        UKOTriple(
            subject_uri=fk_uri_val,
            predicate="rdf:type",
            object_uri="uko-data:ForeignKey",
        )
    )
    triples.append(
        UKOTriple(
            subject_uri=table_uri_val,
            predicate="uko:contains",
            object_uri=fk_uri_val,
        )
    )

    if len(source_cols) != len(target_cols):
        logger.warning(
            "FK column count mismatch: %d source vs %d target columns; "
            "pairing only the shorter list (A8 fix)",
            len(source_cols),
            len(target_cols),
        )

    for src_col, tgt_col in zip(source_cols, target_cols, strict=False):
        source_col_uri = column_uri(resource_uri, table_name, src_col, schema_name)
        target_col_uri = column_uri(resource_uri, ref_table, tgt_col, ref_schema)
        triples.append(
            UKOTriple(
                subject_uri=source_col_uri,
                predicate="uko-data:foreignKeyTo",
                object_uri=target_col_uri,
            )
        )

    return triples


# ---------------------------------------------------------------------------
# Table body parsing
# ---------------------------------------------------------------------------


def parse_table_body(
    body: str,
    resource_uri: str,
    table_name: str,
    table_uri_val: str,
    schema_name: str = "",
) -> list[UKOTriple]:
    """Parse column definitions and constraints inside a table body."""
    triples: list[UKOTriple] = []
    pk_columns: set[str] = set()

    # First pass: find table-level PRIMARY KEY constraints.
    for pk_match in TABLE_CONSTRAINT_PK_RE.finditer(body):
        cols = pk_match.group(1)
        for col_name in cols.split(","):
            cleaned = col_name.strip().strip('"').lower()
            if cleaned:
                pk_columns.add(cleaned)

    entries = split_entries(body)

    for entry in entries:
        stripped = entry.strip()
        if not stripped:
            continue

        # Foreign key constraint
        fk_match = FOREIGN_KEY_RE.search(stripped)
        if fk_match:
            triples.extend(
                extract_fk_triples(
                    fk_match,
                    resource_uri,
                    table_name,
                    table_uri_val,
                    schema_name,
                )
            )
            continue

        # Skip non-column constraint lines.
        # F6 fix: quoted identifiers (e.g. "primary") are never keywords.
        words = stripped.split()
        raw_first = words[0] if words else ""
        if (
            not raw_first.startswith('"')
            and raw_first.strip('"').lower() in NON_COLUMN_KEYWORDS
        ):
            continue

        # Column definition
        col_match = COLUMN_DEF_RE.match(stripped)
        if col_match:
            col_name = col_match.group(1) or col_match.group(2)
            col_type = col_match.group(3).strip()

            remainder = stripped[col_match.end() :]
            is_not_null = bool(NOT_NULL_RE.search(remainder))
            is_pk_inline = bool(PRIMARY_KEY_INLINE_RE.search(remainder))
            is_pk = is_pk_inline or col_name.lower() in pk_columns
            # SERIAL/BIGSERIAL/SMALLSERIAL are implicitly NOT NULL (A7 fix).
            is_serial = col_type.upper() in {
                "SERIAL",
                "BIGSERIAL",
                "SMALLSERIAL",
            }

            triples.extend(
                emit_column_triples(
                    resource_uri,
                    table_name,
                    table_uri_val,
                    col_name,
                    col_type,
                    schema_name=schema_name,
                    is_nullable=not (is_not_null or is_pk or is_serial),
                    is_primary_key=is_pk,
                )
            )

    return triples
