"""Project registry — the §22 multi-project layer.

A deployment hosts one or more projects (§22.1). The git registry mirror
that lets a deployment declare projects lands in M3 and drives this module.
`seed_default_project` (the §22.13 META_REPO backfill) is retired in M3;
the registry mirror (`registry.refresh_registry`) is authoritative.
"""
from __future__ import annotations

import logging

from . import db
from .config import Config

log = logging.getLogger(__name__)

DEFAULT_PROJECT_ID = "default"


def resolved_default_id(config: Config) -> str:
    """The id of the deployment's bootstrap/default project. Plan A: always
    'default' (the re-stamp to a config slug rides Plan B). The config knob is
    read here so Plan B can flip the resolution without touching call sites."""
    return config.default_project_id.strip() or DEFAULT_PROJECT_ID


def restamp_default_project(config: Config) -> None:
    """§22.13 step 1 — one-time rename of the M1 bootstrap project id
    (DEFAULT_PROJECT_ID = 'default') to the deployment's configured default id
    (the DEFAULT_PROJECT_ID env var, e.g. 'ohm'), so the deployment's original
    corpus lands at a meaningful `/p/<id>/` and `default` is never a public URL.

    Renames `project_id` across every project-scoped table (discovered by
    column, so it stays correct as the schema grows), then drops the stale
    bootstrap `projects` row (its data has moved to the configured row, which
    the registry mirror already created). Idempotent and a no-op when the
    configured id is still 'default' or no bootstrap rows remain. Runs at
    startup after the registry mirror, with FK enforcement off for the rename
    (the composite FKs are kept consistent because parent and child rows are
    renamed together) and a foreign_key_check backstop before commit.
    """
    target = resolved_default_id(config)
    if target == DEFAULT_PROJECT_ID:
        return
    conn = db.conn()
    # §22 three-tier: the entry-corpus tables key on collection_id now; detect a
    # lingering bootstrap project by the project-grain `collections.project_id`
    # (the PRAGMA scan below still renames every project_id column dynamically).
    has_rows = conn.execute(
        "SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
    ).fetchone()
    stale_proj = conn.execute(
        "SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
    ).fetchone()
    if not has_rows and not stale_proj:
        return
    if conn.execute("SELECT 1 FROM projects WHERE id = ? LIMIT 1", (target,)).fetchone() is None:
        log.warning("restamp: target project %r not in registry yet; skipping", target)
        return

    tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
    pid_tables = [
        t for t in tables
        if any(c["name"] == "project_id" for c in conn.execute(f"PRAGMA table_info({t})"))
    ]
    conn.execute("PRAGMA foreign_keys = OFF")
    try:
        conn.execute("BEGIN")
        for t in pid_tables:
            conn.execute(
                f"UPDATE {t} SET project_id = ? WHERE project_id = ?",
                (target, DEFAULT_PROJECT_ID),
            )
        # The bootstrap row's data has moved to the configured (registry) row.
        conn.execute("DELETE FROM projects WHERE id = ?", (DEFAULT_PROJECT_ID,))
        violations = conn.execute("PRAGMA foreign_key_check").fetchall()
        if violations:
            conn.execute("ROLLBACK")
            raise RuntimeError(
                f"restamp left foreign-key violations: {[tuple(v) for v in violations]}"
            )
        conn.execute("COMMIT")
    except Exception:
        try:
            conn.execute("ROLLBACK")
        except Exception:
            pass
        raise
    finally:
        conn.execute("PRAGMA foreign_keys = ON")
    log.info("restamp: renamed bootstrap project %r -> %r across %d tables",
             DEFAULT_PROJECT_ID, target, len(pid_tables))


def reconcile_default_collection_id(config: Config) -> None:
    """Heal the §22 migration-029 vs registry-mirror divergence for the default
    project's collection id on a multi-project deployment.

    Migration 029 seeds each project's default collection id as the literal
    'default' only when the DB holds a single project at migration time; with
    ≥2 projects it falls back to the *project id* (avoiding a PK collision —
    029 can't read DEFAULT_PROJECT_ID, there is no env in SQL). But the registry
    mirror (`registry._default_collection_id`) expects the deployment's default
    project to own the collection id 'default'. On an upgrade whose DB already
    held ≥2 projects when 029 ran, the default project's collection is therefore
    named after the project (e.g. 'ohm'), and the next mirror would INSERT a
    second, empty 'default' collection instead of merging — duplicating the
    default corpus and orphaning the entries (which point at 'ohm').

    This is the collection-grain twin of `restamp_default_project`. Run at
    startup BEFORE the registry mirror so the canonical 'default' collection
    already exists when the mirror upserts (merge, not duplicate). Renames the
    divergent collection's id to 'default' and cascades `collection_id` across
    every collection-keyed table, with FK enforcement off for the atomic rename
    and a `foreign_key_check` backstop before commit. Idempotent; a no-op on
    fresh / single-project / already-aligned deployments.
    """
    from .collections import DEFAULT_COLLECTION_ID

    target = resolved_default_id(config)
    if target == DEFAULT_COLLECTION_ID:  # default project already owns 'default'
        return
    conn = db.conn()
    # The 029 ≥2-projects seed names the default project's collection after the
    # project itself; the canonical id the mirror expects is 'default'.
    divergent = conn.execute(
        "SELECT 1 FROM collections WHERE id = ? AND project_id = ? LIMIT 1",
        (target, target),
    ).fetchone()
    if not divergent:
        return
    if conn.execute(
        "SELECT 1 FROM collections WHERE id = ? LIMIT 1", (DEFAULT_COLLECTION_ID,)
    ).fetchone():
        # A 'default' collection already exists (e.g. a prior buggy mirror left a
        # duplicate). Don't auto-merge data — that needs care; leave both for
        # operator cleanup and log loudly.
        log.warning(
            "reconcile: default project %r owns both a %r and a 'default' "
            "collection; skipping auto-rename (manual merge required)",
            target, target,
        )
        return

    cid_tables = [
        t["name"]
        for t in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
        if any(c["name"] == "collection_id"
               for c in conn.execute(f"PRAGMA table_info({t['name']})"))
    ]
    conn.execute("PRAGMA foreign_keys = OFF")
    try:
        conn.execute("BEGIN")
        conn.execute(
            "UPDATE collections SET id = ? WHERE id = ?",
            (DEFAULT_COLLECTION_ID, target),
        )
        for t in cid_tables:
            conn.execute(
                f"UPDATE {t} SET collection_id = ? WHERE collection_id = ?",
                (DEFAULT_COLLECTION_ID, target),
            )
        violations = conn.execute("PRAGMA foreign_key_check").fetchall()
        if violations:
            conn.execute("ROLLBACK")
            raise RuntimeError(
                f"reconcile left foreign-key violations: {[tuple(v) for v in violations]}"
            )
        conn.execute("COMMIT")
    except Exception:
        try:
            conn.execute("ROLLBACK")
        except Exception:
            pass
        raise
    finally:
        conn.execute("PRAGMA foreign_keys = ON")
    log.info(
        "reconcile: renamed default-project collection %r -> 'default' across %d tables",
        target, len(cid_tables),
    )


def prune_absent_projects(config: Config, present_ids: set[str]) -> list[str]:
    """Delete projects the registry no longer declares, plus their collections
    and every project-scoped row. Whole-project granularity only.

    `present_ids` is the authoritative set of project ids from the just-parsed
    registry. Safety is structural: the only caller (`registry.refresh_registry`)
    raises on a read/transport error, and `parse_registry` rejects an empty
    project list — so this is reached ONLY after a real, non-empty registry has
    been parsed. A transient registry-read failure therefore can't trigger a wipe.
    The default project is never pruned regardless of what the registry says.

    Whole-project, not per-collection/entry: a project still present keeps all its
    rows here. Pruning individual entries within a present project belongs to the
    best-effort content cache (whose list can transiently fail), not to this.

    Mirrors `restamp_default_project`'s FK-off + `foreign_key_check` pattern. After
    029 the entry-corpus tables key on `collection_id` (not `project_id`), so the
    delete spans three discovered sets — project_id-keyed tables, collection_id-
    keyed tables (keyed to the stale projects' collections), and the lone non-keyed
    descendant `thread_messages` (FK→threads) — in one transaction with FK
    enforcement off and a `foreign_key_check` backstop that rolls back on any
    dangling reference (so an incomplete delete fails loudly rather than corrupts).
    Returns the pruned project ids (empty when nothing is stale).
    """
    keep = set(present_ids)
    keep.add(resolved_default_id(config))
    keep.add(DEFAULT_PROJECT_ID)
    conn = db.conn()
    stale = [
        r["id"] for r in conn.execute("SELECT id FROM projects").fetchall()
        if r["id"] not in keep
    ]
    if not stale:
        return []

    stale_collections = [
        r["id"] for r in conn.execute(
            f"SELECT id FROM collections WHERE project_id IN ({','.join('?' * len(stale))})",
            stale,
        ).fetchall()
    ]
    tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]

    def _has(table: str, col: str) -> bool:
        return any(c["name"] == col for c in conn.execute(f"PRAGMA table_info({table})"))

    pid_tables = [t for t in tables if t != "projects" and _has(t, "project_id")]
    cid_tables = [t for t in tables if _has(t, "collection_id")]
    p_ph = ",".join("?" * len(stale))
    c_ph = ",".join("?" * len(stale_collections)) if stale_collections else ""

    conn.execute("PRAGMA foreign_keys = OFF")
    try:
        conn.execute("BEGIN")
        # Non-keyed descendant: thread_messages hangs off threads(id), which is
        # project_id-keyed below. Clear it first by thread lineage.
        conn.execute(
            f"DELETE FROM thread_messages WHERE thread_id IN "
            f"(SELECT id FROM threads WHERE project_id IN ({p_ph}))",
            stale,
        )
        if stale_collections:
            for t in cid_tables:
                conn.execute(
                    f"DELETE FROM {t} WHERE collection_id IN ({c_ph})", stale_collections
                )
        for t in pid_tables:  # includes collections + cached_prs + threads/changes/…
            conn.execute(f"DELETE FROM {t} WHERE project_id IN ({p_ph})", stale)
        conn.execute(f"DELETE FROM projects WHERE id IN ({p_ph})", stale)
        violations = conn.execute("PRAGMA foreign_key_check").fetchall()
        if violations:
            conn.execute("ROLLBACK")
            raise RuntimeError(
                f"prune left foreign-key violations: {[tuple(v) for v in violations]}"
            )
        conn.execute("COMMIT")
    except Exception:
        try:
            conn.execute("ROLLBACK")
        except Exception:
            pass
        raise
    finally:
        conn.execute("PRAGMA foreign_keys = ON")
    log.warning(
        "prune: removed %d project(s) absent from the registry: %s "
        "(%d collection(s), across %d project- + %d collection-keyed tables)",
        len(stale), stale, len(stale_collections), len(pid_tables), len(cid_tables),
    )
    return stale


def default_content_repo(config: Config) -> str | None:
    """The content repo the single-corpus mirror reads, from the default
    project's row (filled by the registry mirror). Replaces the retired
    META_REPO. None until the registry mirror has run."""
    row = db.conn().execute(
        "SELECT content_repo FROM projects WHERE id = ?",
        (resolved_default_id(config),),
    ).fetchone()
    return row["content_repo"] if row and row["content_repo"] else None


def content_repo(project_id: str) -> str | None:
    """The content repo for a specific project (§22.3). None if unknown/unset.
    The per-project successor to `default_content_repo` for the write path."""
    row = db.conn().execute(
        "SELECT content_repo FROM projects WHERE id = ?", (project_id,)
    ).fetchone()
    return row["content_repo"] if row and row["content_repo"] else None


def content_repo_for_collection(collection_id: str) -> str | None:
    """The content repo a collection's entries live in (§22 three-tier write
    path, G-15): collection → project → content_repo. None if the collection or
    its project is unknown/unset. The per-collection successor to
    `default_content_repo` for the WRITE path — an entry in a non-default
    project must read/write that project's repo, not the deployment default."""
    from . import collections as collections_mod
    pid = collections_mod.project_of_collection(collection_id)
    return content_repo(pid) if pid else None


def entry_location(config: Config, collection_id: str, slug: str) -> tuple[str, str, str]:
    """The git location `(gitea_org, content_repo, md_path)` of an entry, resolved
    from its collection (§22 three-tier, G-15).

    Repo: the collection's project content_repo, falling back to the deployment
    default project's repo when the collection (or its project) is unknown — so a
    legacy/single-corpus entry still resolves to a usable location rather than an
    empty repo. Path: `<subfolder>/rfcs/<slug>.md`, or `rfcs/<slug>.md` at the
    repo root for a default (subfolder-less) collection.

    This is the single resolver the branch/edit/body/metadata/graduation write
    paths share, replacing the hardcoded `default_content_repo` + `rfcs/<slug>.md`.
    """
    from . import collections as collections_mod
    repo = content_repo_for_collection(collection_id) or (default_content_repo(config) or "")
    sub = collections_mod.subfolder_of(collection_id)
    rfcs_dir = f"{sub}/rfcs" if sub else "rfcs"
    return config.gitea_org, repo, f"{rfcs_dir}/{slug}.md"


def project_initial_state(project_id: str) -> str:
    """§22.4b landing state for new entries in a project's default collection
    (the per-corpus field moved down to the collection in migration 029).
    Defaults to 'super-draft' for an unknown/unset row (today's-flow default)."""
    from . import collections as collections_mod
    return collections_mod.collection_initial_state(
        collections_mod.default_collection_id(project_id)
    )
