// Zugriffsmodell nach der Produktentscheidung vom 04.08.2026 (Telegram 9714):
// angemeldet = alles, anonym = nichts.
//
// Diese Suite prueft NICHT den heutigen Produktionsstand, sondern den Zustand
// NACH supabase-migration-internal-open-access-model.sql. Der CI-Job wendet die
// Migration dafuer in einer eigenen Phase an, nachdem die Isolationssuiten
// gegen das alte Modell gelaufen sind — so bleibt beides belegt: dass die Kette
// den Produktionsstand reproduziert, und dass die Migration daraus das neue
// Soll macht.
//
// Gegated auf RLS_TEST_DATABASE_URL wie die uebrigen RLS-Suiten.

import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { Client } from 'pg'

const DB_URL = process.env.RLS_TEST_DATABASE_URL

const USER_A = 'aaaa1111-0000-0000-0000-000000000001' // Eigentuemer der Fixture-Daten
const USER_B = 'bbbb2222-0000-0000-0000-000000000002' // weder Eigentuemer noch Mitglied
const USER_A_PROJECT = '9999aaaa-0000-0000-0000-0000000000a1'

describe.skipIf(!DB_URL)('Internes Zugriffsmodell — angemeldet alles, anonym nichts', () => {
  let client: Client

  beforeAll(async () => {
    client = new Client({ connectionString: DB_URL })
    await client.connect()
  })

  afterAll(async () => {
    await client?.end()
  })

  async function asUser(uid: string, sql: string, params: unknown[] = []) {
    await client.query('RESET ROLE')
    await client.query("SELECT set_config('request.jwt.claim.sub', $1, false)", [uid])
    await client.query('SET ROLE authenticated')
    return client.query(sql, params)
  }

  async function asAnon(sql: string, params: unknown[] = []) {
    await client.query('RESET ROLE')
    // Leerer Claim → auth.uid() liefert NULL, genau wie bei einem Aufruf ohne
    // Anmeldung. Die Rolle `anon` traegt dieselben Tabellenrechte wie
    // `authenticated` (siehe scripts/rls-test/setup.sql) — was hier blockt,
    // blockt also durch RLS und nicht durch ein fehlendes GRANT.
    await client.query("SELECT set_config('request.jwt.claim.sub', '', false)")
    await client.query('SET ROLE anon')
    return client.query(sql, params)
  }

  async function asSuperuser(sql: string, params: unknown[] = []) {
    await client.query('RESET ROLE')
    return client.query(sql, params)
  }

  // ── Angemeldet: voller Zugriff, unabhaengig von Besitz ──────────────────

  it('ein fremder Nutzer liest das Projekt eines anderen', async () => {
    const r = await asUser(USER_B, 'SELECT count(*)::int AS n FROM public.projects WHERE id = $1', [
      USER_A_PROJECT,
    ])
    expect(r.rows[0].n).toBe(1)
  })

  it('ein fremder Nutzer liest die QAF-Vergleiche eines anderen', async () => {
    const r = await asUser(USER_B, 'SELECT count(*)::int AS n FROM public.qaf_comparison')
    expect(r.rows[0].n).toBeGreaterThanOrEqual(1)
  })

  it('ein fremder Nutzer liest die Wertstroeme eines anderen', async () => {
    const r = await asUser(USER_B, 'SELECT count(*)::int AS n FROM public.value_stream_maps')
    expect(r.rows[0].n).toBeGreaterThanOrEqual(1)
  })

  it('ein fremder Nutzer aendert einen fremden QAF-Vergleich', async () => {
    const r = await asUser(
      USER_B,
      "UPDATE public.qaf_comparison SET status = 'geaendert-durch-b' WHERE part_number = 'USERA-PART'",
    )
    expect(r.rowCount).toBeGreaterThanOrEqual(1)
  })

  it('ein fremder Nutzer legt im fremden Projekt eine Zeile an und wieder ab', async () => {
    const inserted = await asUser(
      USER_B,
      'INSERT INTO public.qaf_comparison (project_id, part_number) VALUES ($1, $2) RETURNING id',
      [USER_A_PROJECT, 'ANGELEGT-VON-B'],
    )
    expect(inserted.rowCount).toBe(1)

    const removed = await asUser(
      USER_B,
      "DELETE FROM public.qaf_comparison WHERE part_number = 'ANGELEGT-VON-B'",
    )
    expect(removed.rowCount).toBe(1)
  })

  it('ein fremder Nutzer aendert ein fremdes Projekt', async () => {
    const r = await asUser(
      USER_B,
      "UPDATE public.projects SET plant_location = 'geaendert-durch-b' WHERE id = $1",
      [USER_A_PROJECT],
    )
    expect(r.rowCount).toBe(1)
  })

  // ── Anonym: nichts ──────────────────────────────────────────────────────

  it('anonym liest keine Projekte', async () => {
    const r = await asAnon('SELECT count(*)::int AS n FROM public.projects')
    expect(r.rows[0].n).toBe(0)
  })

  it('anonym liest keine QAF-Vergleiche', async () => {
    const r = await asAnon('SELECT count(*)::int AS n FROM public.qaf_comparison')
    expect(r.rows[0].n).toBe(0)
  })

  it('anonym liest keine Wertstroeme', async () => {
    const r = await asAnon('SELECT count(*)::int AS n FROM public.value_stream_maps')
    expect(r.rows[0].n).toBe(0)
  })

  it('anonym darf nicht schreiben', async () => {
    await expect(
      asAnon('INSERT INTO public.qaf_comparison (project_id, part_number) VALUES ($1, $2)', [
        USER_A_PROJECT,
        'ANONYM',
      ]),
    ).rejects.toThrow(/row-level security/i)
  })

  it('anonym aendert und loescht nichts', async () => {
    const updated = await asAnon("UPDATE public.projects SET plant_location = 'anon'")
    expect(updated.rowCount).toBe(0)
    const deleted = await asAnon('DELETE FROM public.qaf_comparison')
    expect(deleted.rowCount).toBe(0)
  })

  // ── Struktur: das Modell muss ueberall gleich aussehen ──────────────────

  it('jede Tabelle in public hat RLS aktiv', async () => {
    const r = await asSuperuser(`
      SELECT c.relname
        FROM pg_class c
        JOIN pg_namespace n ON n.oid = c.relnamespace
       WHERE n.nspname = 'public' AND c.relkind = 'r' AND NOT c.relrowsecurity
       ORDER BY c.relname
    `)
    expect(r.rows.map((row) => row.relname)).toEqual([])
  })

  it('jede Tabelle ist einer Zugriffsklasse zugeordnet', async () => {
    // Die Klassifikation ist die Landkarte des Modells. Fehlt eine Tabelle
    // darin, ist unentschieden, ob sie Fachdaten oder Sicherheitsebene ist —
    // und ein neu hinzugekommenes Modul faellt still durchs Raster.
    const r = await asSuperuser(`
      SELECT c.relname
        FROM pg_class c
        JOIN pg_namespace n ON n.oid = c.relnamespace
        LEFT JOIN public.rls_access_class k ON k.tablename = c.relname
       WHERE n.nspname = 'public' AND c.relkind = 'r' AND k.tablename IS NULL
       ORDER BY c.relname
    `)
    expect(r.rows.map((row) => row.relname)).toEqual([])
  })

  it('jede Fachtabelle traegt genau eine Policy, und zwar die offene', async () => {
    // Die Liste der Ausnahmen wird hier NICHT wiederholt — sie kommt aus
    // public.rls_access_class, die die Migration selbst schreibt. Zwei Orte
    // wuerden auseinanderdriften, und der Test bestaetigte dann seine eigene
    // Kopie statt das Modell.
    //
    // LEFT JOIN auf pg_policies, nicht Einstieg dort: eine Tabelle, die ALLE
    // Policies verliert, steht in pg_policies gar nicht mehr. Ein Test, der
    // nur dort sucht, bliebe bei genau diesem Schaden gruen — die Tabelle
    // waere dann fuer jeden Angemeldeten unsichtbar, und niemand merkte es.
    const r = await asSuperuser(`
      SELECT k.tablename,
             count(p.policyname)::int AS anzahl,
             min(p.policyname) AS name,
             min(p.cmd) AS cmd,
             min(array_to_string(p.roles, ',')) AS roles,
             min(p.qual) AS qual,
             min(p.with_check) AS with_check
        FROM public.rls_access_class k
        LEFT JOIN pg_policies p
          ON p.schemaname = 'public' AND p.tablename = k.tablename
       WHERE k.access_class = 'open_business_data'
       GROUP BY k.tablename
      HAVING count(p.policyname) <> 1
          OR min(p.policyname) <> k.tablename || '_internal_rw'
          OR min(p.cmd) <> 'ALL'
          OR min(array_to_string(p.roles, ',')) <> 'authenticated'
          OR coalesce(min(p.qual), '') <> 'true'
          OR coalesce(min(p.with_check), '') <> 'true'
       ORDER BY k.tablename
    `)
    expect(r.rows).toEqual([])
  })

  it('die Sicherheitsebene traegt nirgends eine offene Schreibregel', async () => {
    const r = await asSuperuser(`
      SELECT p.tablename, p.policyname, k.access_class
        FROM pg_policies p
        JOIN public.rls_access_class k ON k.tablename = p.tablename
       WHERE p.schemaname = 'public'
         AND k.access_class LIKE 'protected%'
         AND p.policyname LIKE '%\\_internal\\_rw'
       ORDER BY p.tablename
    `)
    expect(r.rows).toEqual([])
  })

  it('die geschlossene Sicherheitsebene bekommt auch keine offene Leseregel', async () => {
    const r = await asSuperuser(`
      SELECT p.tablename, p.policyname
        FROM pg_policies p
        JOIN public.rls_access_class k ON k.tablename = p.tablename
       WHERE p.schemaname = 'public'
         AND k.access_class = 'protected_security_control_plane_closed'
         AND p.policyname LIKE '%\\_internal\\_%'
       ORDER BY p.tablename
    `)
    expect(r.rows).toEqual([])
  })

  it('auf den Fachdaten nennt keine Policy anonym oder public', async () => {
    // Bewusst auf die offene Klasse begrenzt. Die Sicherheitsebene traegt
    // Altregeln, die ohne TO-Klausel angelegt wurden und damit formal fuer
    // `public` gelten — etwa die 13 cp_*-Tabellen aus dem Bootstrap-Dump mit
    // `USING (auth.role() = 'service_role')`. Fuer anonym ist auth.role()
    // NULL, das Praedikat also falsch: kein Zugriff. Diese Regeln pauschal zu
    // verbieten waere eine Aussage ueber die Schreibweise statt ueber die
    // Wirkung — die Wirkung pruefen die anonym-Tests weiter unten.
    const r = await asSuperuser(`
      SELECT p.tablename, p.policyname, array_to_string(p.roles, ',') AS roles
        FROM pg_policies p
        JOIN public.rls_access_class k ON k.tablename = p.tablename
       WHERE p.schemaname = 'public'
         AND k.access_class = 'open_business_data'
         AND ('anon' = ANY (p.roles) OR 'public' = ANY (p.roles))
       ORDER BY p.tablename, p.policyname
    `)
    expect(r.rows).toEqual([])
  })

  // ── Grenze: Identitaet und Rechte bleiben geschuetzt ────────────────────

  it('Nutzerprofile sind fuer alle Angemeldeten lesbar', async () => {
    const r = await asUser(USER_B, 'SELECT count(*)::int AS n FROM public.user_profiles')
    expect(r.rows[0].n).toBeGreaterThanOrEqual(1)
  })

  it('Rollen bleiben unveraenderbar — weder fremde noch die eigene', async () => {
    // Fremdes Profil: die Schreibregel aus dem Bestand (up_update) laesst nur
    // admin/masteradmin oder den Eigentuemer durch — user_b ist keines von beidem.
    const fremd = await asUser(
      USER_B,
      `UPDATE public.user_profiles
          SET role_id = (SELECT id FROM public.roles WHERE code = 'admin' LIMIT 1)
        WHERE auth_user_id = $1`,
      [USER_A],
    )
    expect(fremd.rowCount).toBe(0)

    // Eigenes Profil: hier greift der Trigger aus KAR-770. Ohne diese zweite
    // Haelfte waere die erste kein Beweis gegen Rechteausweitung — jeder Nutzer
    // darf sein eigenes Profil schreiben.
    await expect(
      asUser(
        USER_A,
        `UPDATE public.user_profiles
            SET role_id = (SELECT id FROM public.roles WHERE code = 'admin' LIMIT 1)
          WHERE auth_user_id = $1`,
        [USER_A],
      ),
    ).rejects.toThrow(/role_id cannot be changed/i)
  })

  it('Protokolle sind lesbar, aber fuer normale Nutzer nicht aenderbar', async () => {
    const gelesen = await asUser(USER_B, 'SELECT count(*)::int AS n FROM public.audit_log')
    expect(gelesen.rows[0].n).toBeGreaterThanOrEqual(0)

    // Kein Ergebnis heisst hier: die Zeilen sind fuer UPDATE/DELETE unsichtbar,
    // weil es dafuer keine Policy gibt. Eintraege entstehen ausschliesslich
    // ueber INSERT durch Serverlogik und Trigger — append-only.
    const geaendert = await asUser(USER_B, "UPDATE public.audit_log SET action = 'manipuliert'")
    expect(geaendert.rowCount).toBe(0)
    const geloescht = await asUser(USER_B, 'DELETE FROM public.user_audit_log')
    expect(geloescht.rowCount).toBe(0)
  })

  it('auf den Protokollen existiert keine Aenderungs-Policy', async () => {
    // Der Verhaltenstest oben prueft den Ist-Zustand mit den vorhandenen
    // Zeilen. Diese Struktur-Pruefung schliesst aus, dass eine spaetere
    // Migration eine UPDATE-/DELETE-Regel hinzufuegt, ohne dass es auffaellt.
    const r = await asSuperuser(`
      SELECT tablename, policyname, cmd
        FROM pg_policies
       WHERE schemaname = 'public'
         AND tablename IN ('audit_log', 'user_audit_log', 'master_data_audit_log')
         AND cmd IN ('UPDATE', 'DELETE', 'ALL')
       ORDER BY tablename, policyname
    `)
    expect(r.rows).toEqual([])
  })

  it('niemand traegt sich selbst als Berater mit Adminrolle ein', async () => {
    // consultants sieht wie Stammdaten aus, ist aber eine Rechtequelle:
    // acht SECURITY-DEFINER-Funktionen lesen `role` als Berechtigungsnachweis.
    // Lesen ist offen — das ist der Fachdaten-Anteil.
    const gelesen = await asUser(USER_B, 'SELECT count(*)::int AS n FROM public.consultants')
    expect(gelesen.rows[0].n).toBeGreaterThanOrEqual(0)

    // Schreiben nicht. Der INSERT nennt nur Spalten mit Default oder NULL —
    // scheitert er, dann an der Policy und nicht an einer Pflichtspalte.
    await expect(
      asUser(
        USER_B,
        "INSERT INTO public.consultants (auth_user_id, display_name, role) VALUES ($1, 'Selbstbedienung', 'masteradmin')",
        [USER_B],
      ),
    ).rejects.toThrow(/row-level security/i)

    const erhoben = await asUser(USER_B, "UPDATE public.consultants SET role = 'masteradmin'")
    expect(erhoben.rowCount).toBe(0)
  })

  it('Import-Protokolle sind fuer normale Nutzer nicht schreibbar', async () => {
    await expect(
      asUser(
        USER_B,
        "INSERT INTO public.master_data_import_jobs (dataset_type) VALUES ('gefaelscht')",
      ),
    ).rejects.toThrow(/row-level security/i)

    const geaendert = await asUser(
      USER_B,
      "UPDATE public.master_data_import_jobs SET import_status = 'manipuliert'",
    )
    expect(geaendert.rowCount).toBe(0)
  })

  it('Zugangs-Token der oeffentlichen Einreichung bleiben unlesbar', async () => {
    // intake_submission_tokens steht in der geschlossenen Klasse. Der Weg
    // dorthin fuehrt ueber consultants.role — deshalb steht dieser Test
    // neben dem consultants-Test und nicht irgendwo allein.
    //
    // Erst nachweisen, dass ueberhaupt eine Zeile existiert. Ohne diesen
    // Schritt waere die Aussage unten wertlos: eine leere Tabelle liefert
    // jedem Nutzer 0 Treffer, auch ganz ohne Policy.
    const vorhanden = await asSuperuser(
      'SELECT count(*)::int AS n FROM public.intake_submission_tokens',
    )
    expect(vorhanden.rows[0].n).toBeGreaterThanOrEqual(1)

    const r = await asUser(USER_B, 'SELECT count(*)::int AS n FROM public.intake_submission_tokens')
    expect(r.rows[0].n).toBe(0)
  })

  it('die geschlossene Sicherheitsebene bleibt fuer normale Nutzer unlesbar', async () => {
    // email_queue traegt Einladungs- und Aktivierungsmails. Die bestehende
    // Regel laesst nur admin/masteradmin lesen — user_b ist keines von beidem.
    const r = await asUser(USER_B, 'SELECT count(*)::int AS n FROM public.email_queue')
    expect(r.rows[0].n).toBe(0)
  })

  it('anonym kommt auch an die Sicherheitsebene nicht heran', async () => {
    const profile = await asAnon('SELECT count(*)::int AS n FROM public.user_profiles')
    expect(profile.rows[0].n).toBe(0)
    const protokoll = await asAnon('SELECT count(*)::int AS n FROM public.audit_log')
    expect(protokoll.rows[0].n).toBe(0)
    const rollen = await asAnon('SELECT count(*)::int AS n FROM public.roles')
    expect(rollen.rows[0].n).toBe(0)
    // Auch die geschlossene Klasse, deren Altregeln ohne TO-Klausel formal
    // fuer `public` gelten: entscheidend ist die Wirkung, nicht die Schreibweise.
    const einladungen = await asAnon('SELECT count(*)::int AS n FROM public.email_queue')
    expect(einladungen.rows[0].n).toBe(0)
    // Die Control-Plane-Reste gibt es nur im Testschema (in Produktion am
    // 2026-05-26 entfernt). Nach einem frischen Dump sind sie weg — die
    // Pruefung faellt dann still aus, statt an einer fehlenden Tabelle zu
    // scheitern und wie ein Sicherheitsbefund auszusehen.
    const vorhanden = await asSuperuser("SELECT to_regclass('public.cp_tenants') IS NOT NULL AS da")
    if (vorhanden.rows[0].da) {
      const controlPlane = await asAnon('SELECT count(*)::int AS n FROM public.cp_tenants')
      expect(controlPlane.rows[0].n).toBe(0)
    }
  })

  // ── Rueckweg ────────────────────────────────────────────────────────────

  it('der Policy-Snapshot fuer den Rollback ist gefuellt', async () => {
    const r = await asSuperuser('SELECT count(*)::int AS n FROM public.rls_policy_snapshot')
    expect(r.rows[0].n).toBeGreaterThan(0)
  })
})
