"""Regression coverage for #266 — Edit modal on BQ materialized rows
silently nulled bucket / source_table or showed empty inputs.

Three bugs traced from the Save chain (admin_tables.html JS → PUT
/api/admin/registry/{id} → table_registry upsert):

1. `saveBqTabEdit` (synced/custom) sent `bucket: null, source_table:
   null` on every save — not just on a true remote→materialized mode
   flip. An admin editing only the description of an already-
   materialized custom-SQL row wiped persisted bucket/source_table.

2. `_buildBigQueryPayload` (synced/whole) at register time DID NOT
   send bucket/source_table — only source_query. So whole-table
   materialized rows persisted with bucket=NULL from day one. The
   Edit modal then read empty `table.bucket` and rendered empty
   Dataset/Table inputs over the SELECT * SQL.

3. `_openEditBqModal` populated the Dataset/Table inputs from
   `table.bucket || ''` only — for whole-table rows registered pre-#266
   (bucket=NULL), the inputs stayed empty. Saving with the empty
   inputs would synthesize a broken `SELECT * FROM bq."".""` SQL.

These tests pin the server-side contract that the client now relies
on: PUTs that OMIT bucket/source_table keys preserve existing values
(thanks to `exclude_unset=True` in `app/api/admin.update_table`).
The template-grep test pins the JS-side fixes themselves.
"""
from __future__ import annotations


def _auth(token: str) -> dict:
    return {"Authorization": f"Bearer {token}"}


def test_put_omitting_bucket_preserves_existing_value_on_materialized_row(
    seeded_app, bq_instance, stub_bq_extractor,
):
    """Bug 1 fix contract: when the new Edit-modal save path omits
    bucket/source_table from the JSON body on a no-op-mode save, the
    server preserves the existing values.

    Pre-#266 the JS sent `bucket: null, source_table: null` on every
    save in the synced/custom branch — this test pins that an OMITTED
    key on a custom-SQL materialized row preserves bucket. Same
    invariant the v26 PUT-preservation tests pin for primary_key /
    sync_strategy, but specific to bucket/source_table on a
    materialized row (which the older tests didn't cover)."""
    client = seeded_app["client"]
    headers = _auth(seeded_app["admin_token"])

    # Register a custom-SQL materialized BQ row that ALSO has bucket+
    # source_table set. Both can coexist — bucket is documentary, the
    # SQL is the source of truth. Pre-#266 a curl PUT could set this
    # state; post-#266 the whole-table register flow also produces it.
    custom_sql = (
        "SELECT order_id, customer_id, total_usd "
        "FROM `myproj.finance.orders` "
        "WHERE event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)"
    )
    resp = client.post(
        "/api/admin/register-table",
        json={
            "name": "preserve_bucket_test",
            "source_type": "bigquery",
            "query_mode": "materialized",
            "bucket": "finance",
            "source_table": "orders",
            "source_query": custom_sql,
        },
        headers=headers,
    )
    assert resp.status_code in (200, 201, 202), resp.text

    # PUT a description-only change — body OMITS bucket/source_table,
    # mirroring the post-#266 JS payload-builder behavior on a no-op
    # mode save.
    resp = client.put(
        "/api/admin/registry/preserve_bucket_test",
        json={"description": "Customer orders, last 30 days"},
        headers=headers,
    )
    assert resp.status_code == 200, resp.text

    reg = client.get("/api/admin/registry", headers=headers).json()
    row = next(t for t in reg["tables"] if t["id"] == "preserve_bucket_test")
    assert row["bucket"] == "finance"
    assert row["source_table"] == "orders"
    assert row["description"] == "Customer orders, last 30 days"
    assert row["source_query"] == custom_sql
    assert row["query_mode"] == "materialized"


def test_put_explicit_null_clears_bucket_on_mode_flip(
    seeded_app, bq_instance, stub_bq_extractor,
):
    """Bug 1 fix contract: a TRUE mode-flip save (remote → materialized
    custom) still wants to clear stale bucket/source_table from the
    old mode. The JS sends explicit null in that case; the server
    persists NULL. This pins the existing exclude_unset semantics
    that distinguish 'omitted' (preserve) from 'explicit null' (clear)
    — see admin.py:2636-2654 inline comment."""
    client = seeded_app["client"]
    headers = _auth(seeded_app["admin_token"])

    # Register remote BQ row with bucket+source_table.
    resp = client.post(
        "/api/admin/register-table",
        json={
            "name": "flip_remote_to_custom",
            "source_type": "bigquery",
            "bucket": "finance",
            "source_table": "orders",
        },
        headers=headers,
    )
    assert resp.status_code in (200, 201, 202), resp.text

    # Mode flip: remote → materialized custom-SQL. JS sends explicit
    # null in this branch (its `_editOriginalQueryMode !== 'materialized'`
    # condition fires).
    new_sql = "SELECT 1 AS placeholder"
    resp = client.put(
        "/api/admin/registry/flip_remote_to_custom",
        json={
            "query_mode": "materialized",
            "source_query": new_sql,
            "bucket": None,
            "source_table": None,
        },
        headers=headers,
    )
    assert resp.status_code == 200, resp.text

    reg = client.get("/api/admin/registry", headers=headers).json()
    row = next(t for t in reg["tables"] if t["id"] == "flip_remote_to_custom")
    assert row["query_mode"] == "materialized"
    assert row["bucket"] is None
    assert row["source_table"] is None
    assert row["source_query"] == new_sql


def test_register_whole_table_materialized_persists_bucket(
    seeded_app, bq_instance, stub_bq_extractor,
):
    """Bug 2/3 fix contract: post-#266 the JS whole-table register
    branch sends bucket+source_table alongside source_query so a
    subsequent Edit modal can pre-fill those inputs from the
    persisted values (instead of leaving them empty / re-parsing
    SQL).

    The server already accepts this shape — `_validate_bigquery_register_payload`
    treats bucket/source_table as optional when source_query is
    provided. This test pins that the persisted row carries both."""
    client = seeded_app["client"]
    headers = _auth(seeded_app["admin_token"])

    # Mirror the post-#266 JS payload for synced/whole register.
    resp = client.post(
        "/api/admin/register-table",
        json={
            "name": "whole_table_with_bucket",
            "source_type": "bigquery",
            "query_mode": "materialized",
            "bucket": "analytics",
            "source_table": "page_views",
            "source_query": 'SELECT * FROM bq."analytics"."page_views"',
        },
        headers=headers,
    )
    assert resp.status_code in (200, 201, 202), resp.text

    reg = client.get("/api/admin/registry", headers=headers).json()
    row = next(t for t in reg["tables"] if t["id"] == "whole_table_with_bucket")
    assert row["bucket"] == "analytics"
    assert row["source_table"] == "page_views"
    assert row["query_mode"] == "materialized"
    # source_query persisted verbatim (DuckDB three-part-alias form;
    # the materialize wrapper translates this to BQ-native at exec).
    assert 'bq."analytics"."page_views"' in row["source_query"]


def test_admin_tables_template_has_266_fixes():
    """Pin the three JS-side fixes in the rendered template. These
    are tested by string presence rather than headless browser since
    Agnes has no JS test harness. A future maintainer who reverts
    one of them will trip an obvious failure here."""
    from pathlib import Path
    tpl = Path(__file__).parent.parent / "app" / "web" / "templates" / "admin_tables.html"
    text = tpl.read_text(encoding="utf-8")

    # Bug 1: saveBqTabEdit synced/custom branch must guard the null
    # writes with a mode-flip check, not null unconditionally.
    assert "_editOriginalQueryMode !== 'materialized'" in text, (
        "Bug 1 regression: saveBqTabEdit nulls bucket/source_table "
        "unconditionally — must guard on a real mode flip."
    )
    # The unconditional null pattern from pre-#266 must be GONE in
    # the custom branch. We grep for the surrounding comment-tail.
    assert "payload.bucket = null;\n            payload.source_table = null;\n        } else" not in text, (
        "Bug 1 regression: the unconditional `payload.bucket = null` "
        "block is back in the synced/custom branch."
    )

    # Bug 2/3: _buildBigQueryPayload synced/whole branch must include
    # bucket+source_table in the JSON.
    assert "bucket: dataset," in text, (
        "Bug 2/3 regression: _buildBigQueryPayload whole-table branch "
        "must send bucket alongside source_query so Edit can pre-fill."
    )

    # _openEditBqModal must parse dataset+source_table out of the
    # source_query when bucket is empty (back-compat for rows
    # registered pre-#266 with bucket=NULL).
    assert "if (!preDataset && !preSourceTable && isAutoSelectStar)" in text, (
        "Bug 2 regression: _openEditBqModal must fall back to parsing "
        "source_query for pre-#266 whole-table rows with bucket=NULL."
    )
