#!/usr/bin/env python3
"""3E Akademi — denetim zinciri bagimsiz dogrulayici.

Bu betik, zincirin uygulama kodu KULLANILMADAN yeniden hesaplanabildigini
gosterir. Icinde 3E'nin hicbir kutuphanesi yoktur; yaptigi is sha256 almak ve
metin birlestirmektir.

IKI CALISMA BICIMI
------------------

1) Dogrudan veritabanindan (psycopg kurulu ise):

       python3 dogrula.py --dsn "postgresql://kullanici:parola@sunucu:5432/veritabani"

2) Sifir bagimlilik — psql ile alinmis dosyadan:

       psql "$DSN" -At -F $'\\t' -f sorgular.sql > satirlar.tsv
       python3 dogrula.py --rows satirlar.tsv

   Ikinci bicim delil acisindan daha guclu: veriyi veritabanindan cikaran sey
   bu betik degil, dogrulayan kisinin kendi psql'idir.

HEDEF SURUM
-----------

Python 3.12 ile gelistirildi ve orada test edildi. Yalniz standart kutuphane
kullanildigi icin 3.9 ve uzeri her surumde calisir; psycopg yalniz birinci
bicimde gerekir.

NE YAPAR
--------

Her muhur icin:
  1. Muhrun kapsadigi satirlari okur (from_id ... to_id).
  2. Her satiri yaprak-kurali.md'deki kurala gore tek bir metne cevirir.
  3. Metinlerin sha256 ozetlerini ikiser ikiser birlestirerek koke ulasir.
  4. Bulunan koku muhurler.json'daki merkle_root ile karsilastirir.
  5. Zincir ozetini sha256(previous_chain_hash + merkle_root) ile yeniden
     hesaplar ve chain_hash ile karsilastirir.

Cikis kodu: her sey esitse 0, tek bir uyusmazlik varsa 1.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import sys
from pathlib import Path

# Muhur kuralinin adi. muhurler.json icindeki her muhur hangi kurala gore
# alindigini soyler; bu betik yalniz asagidaki kurali bilir ve baska bir kuralla
# alinmis muhru "dogrulanamadi" diye raporlar, "kirik" diye degil.
KNOWN_RULE = "sha256-utc-iso-v2"


def leaf_part(value: str | None) -> str:
    """Tek bir alanin kodlanmis hali.

    Uzunluk onekli, ayirici DEGIL: denetim satirlari serbest metin tasir (url,
    tarayici bilgisi, etiket) ve her ayirici karakteri o metinlerin icinde
    gecebilir. Uzunluk, degerin nerede bittigini soyler. NULL kendi isaretini
    tasir, ki bos metinle karistirilamasin.

    Uzunluk KARAKTER degil BAYT sayisidir: bayt kesindir, karakterin ne oldugu
    yoruma baglidir.
    """
    if value is None:
        return "-;"

    encoded = value.encode("utf-8")

    return f"{len(encoded)}:{value};"


def sha256_hex(data: str) -> str:
    return hashlib.sha256(data.encode("utf-8")).hexdigest()


def merkle_root(leaves: list[str]) -> str | None:
    """Yapraklardan koke.

    Yaprak yoksa kok de yoktur. Tek yaprak varsa kendi ozeti koktur. Tek sayida
    dugum kalirsa sonuncusu bir ust kata oldugu gibi tasinir — es olmadan kendi
    kendisiyle eslenmez, cunku bu ayni yapragin iki kez sayilmasi olurdu.
    """
    if not leaves:
        return None

    level = [sha256_hex(leaf) for leaf in leaves]

    while len(level) > 1:
        nxt: list[str] = []

        for index in range(0, len(level) - 1, 2):
            nxt.append(sha256_hex(level[index] + level[index + 1]))

        if len(level) % 2 == 1:
            nxt.append(level[-1])

        level = nxt

    return level[0]


def chain_link(previous: str | None, root: str) -> str:
    """Zincir halkasi: onceki halkanin ozeti + bu kumenin koku.

    Ilk muhurde onceki halka yoktur ve bos metin olarak alinir — NULL ile bos
    metnin ayni sayilmasi burada bilerekdir, cunku "onceki yok" durumu tek
    turludur.
    """
    return sha256_hex((previous or "") + root)


def rows_from_file(path: Path) -> dict[tuple[str, int], list[str]]:
    """psql ciktisini okur.

    Beklenen bicim, sorgular.sql'in urettigi bicimdir: her satir
    `audit_table <TAB> id <TAB> yaprak_metni`. Yaprak metni sorgu tarafinda
    olusturulur; boylece bu betik sutun listesini bilmek zorunda kalmaz ve
    kural tek yerde durur.
    """
    grouped: dict[tuple[str, int], list[str]] = {}

    with path.open(encoding="utf-8") as handle:
        for line in handle:
            line = line.rstrip("\n")

            if not line:
                continue

            parts = line.split("\t", 2)

            if len(parts) != 3:
                raise SystemExit(f"Beklenmeyen satir bicimi: {line[:80]}")

            table, row_id, leaf = parts
            grouped.setdefault((table, int(row_id)), []).append(leaf)

    return grouped


def leaves_for_seal_from_file(
    rows: dict[tuple[str, int], list[str]],
    table: str,
    from_id: int,
    to_id: int,
) -> list[str]:
    # ⚠ from_id DISLAYICI, to_id kapsayicidir. Muhur "bir onceki muhrun
    # bittigi yerden" baslar; from_id o onceki muhrun son id'sidir ve bu kumeye
    # ait DEGILDIR. Veritabanindaki fonksiyon da tam olarak boyle yazar:
    #     WHERE id > p_from_id AND id <= p_to_id
    # Bunu kapsayici okumak, her muhre fazladan bir satir katar ve kok tutmaz.
    selected = [
        (row_id, leaves[0])
        for (row_table, row_id), leaves in rows.items()
        if row_table == table and from_id < row_id <= to_id
    ]

    selected.sort(key=lambda item: item[0])

    return [leaf for _, leaf in selected]


def leaves_for_seal_from_database(connection, table: str, from_id: int, to_id: int) -> list[str]:
    """Ayni yapraklari veritabanindan alir.

    Sorgu sorgular.sql ile AYNI ifadeyi kullanir — iki yolun ayni seyi
    hesaplamasi bu betigin degil, o dosyanin garantisidir.
    """
    # sorgular.sql tum tablolari dolasir; burada tek muhrun araligi istenir.
    # ⚠ `id > from_id`, BETWEEN degil: from_id dislayicidir (yukaridaki nota
    # bakiniz). Tablo adi mühürden gelir ve iki sabit degerden biri olabilir;
    # yine de bir liste ile sinirlanir, cunku disaridan gelen bir ad SQL'e
    # dogrudan yazilmamalidir.
    if table not in ("audit_writes", "audit_access"):
        raise SystemExit(f"Bilinmeyen tablo: {table}")

    statement = (
        f"SELECT id, app.audit_leaf_line(t.*) FROM {table} t "
        "WHERE id > %s AND id <= %s ORDER BY id"
    )

    with connection.cursor() as cursor:
        cursor.execute(statement, (from_id, to_id))

        return [row[1] for row in cursor.fetchall()]


def main() -> int:
    parser = argparse.ArgumentParser(description="Denetim zinciri bagimsiz dogrulayici")
    parser.add_argument("--seals", default="muhurler.json", help="muhurler.json yolu")
    parser.add_argument("--dsn", help="PostgreSQL baglanti adresi (psycopg gerekir)")
    parser.add_argument("--rows", help="psql ile alinmis satir dosyasi (bagimlilik gerektirmez)")
    arguments = parser.parse_args()

    if not arguments.dsn and not arguments.rows:
        parser.error("--dsn veya --rows verilmelidir. Ayrintilar icin BENIOKU.md.")

    document = json.loads(Path(arguments.seals).read_text(encoding="utf-8"))
    seals = document["muhurler"]

    if not seals:
        print("Pakette muhur yok.")
        return 0

    connection = None
    file_rows: dict[tuple[str, int], list[str]] = {}

    if arguments.dsn:
        try:
            import psycopg
        except ImportError:
            raise SystemExit(
                "psycopg kurulu degil. Kurmadan dogrulamak icin BENIOKU.md'deki "
                "ikinci bicimi kullanin: psql ile satirlari dosyaya alip --rows verin."
            )

        connection = psycopg.connect(arguments.dsn)
    else:
        file_rows = rows_from_file(Path(arguments.rows))

    failures = 0
    previous_by_table: dict[str, str | None] = {}

    for seal in seals:
        table = seal["audit_table"]
        label = f"{table} {seal['from_id']}→{seal['to_id']} ({seal['epoch_date']})"

        if seal["hash_algorithm"] != KNOWN_RULE:
            print(f"?  {label}: '{seal['hash_algorithm']}' kuralini bu betik bilmiyor — dogrulanamadi")
            previous_by_table[table] = seal["chain_hash"]
            continue

        if connection is not None:
            leaves = leaves_for_seal_from_database(connection, table, seal["from_id"], seal["to_id"])
        else:
            leaves = leaves_for_seal_from_file(file_rows, table, seal["from_id"], seal["to_id"])

        computed_root = merkle_root(leaves)
        problems: list[str] = []

        if len(leaves) != seal["row_count"]:
            problems.append(f"satir sayisi {len(leaves)}, muhurde {seal['row_count']}")

        if computed_root != seal["merkle_root"]:
            problems.append(f"kok {computed_root}, muhurde {seal['merkle_root']}")

        expected_link = chain_link(seal["previous_chain_hash"], seal["merkle_root"])

        if expected_link != seal["chain_hash"]:
            problems.append("zincir ozeti kendi kokunden dogmuyor")

        # Halkalar birbirini takip ediyor mu? Ilk muhurde onceki yoktur.
        expected_previous = previous_by_table.get(table)

        if table in previous_by_table and seal["previous_chain_hash"] != expected_previous:
            problems.append("halka kendinden onceki muhru takip etmiyor")

        previous_by_table[table] = seal["chain_hash"]

        if problems:
            failures += 1
            print(f"X  {label}: " + "; ".join(problems))
        else:
            print(f"OK {label}")

    if connection is not None:
        connection.close()

    print()

    if failures:
        print(f"UYUSMAZLIK: {failures} muhur dogrulanmadi.")
        return 1

    print(f"Zincir dogrulandi: {len(seals)} muhur.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
