"""
Shared repo context resolution for hooks in sibling-repos topology.

In sibling-repos (type 4), hooks run from the workspace parent but need to
read config and session files from the active child repo. This module provides
detection and path resolution.

Usage in hooks:
    from repo_context import resolve_repo_dir
    repo_dir = resolve_repo_dir()          # from active-repo pointer
    repo_dir = resolve_repo_dir(file_path) # from file being edited
    config = Path(repo_dir) / '.flydocs' / 'config.json'
"""

import json
import os
import re
import sys
from pathlib import Path

# FLY-1715: the worktree-aware walk is shared with the workflow scripts, which
# resolve the same session directory and must not disagree with the hooks.
# Appended, not inserted, so the scripts directory never shadows the stdlib.
sys.path.append(
    str(Path(__file__).resolve().parent.parent
        / 'skills' / 'flydocs-workflow' / 'scripts'))
try:
    import git_worktree  # noqa: E402
except ImportError:
    # Every hook imports this module, so it must load even when the skills
    # folder is absent (a skipPaths entry, a half-finished update). Without
    # the shared helper, resolution falls back to the plain walk-up it used
    # before FLY-1715: worktrees outside the workspace stop resolving, and
    # nothing else changes.
    git_worktree = None

# FLY-1714: per-thread session state lives in one module shared with the
# workflow scripts, so a hook and `issues.py` can never disagree about which
# file is a thread's. Same contract as `git_worktree`: every hook imports this
# file, so a missing module must not stop one from loading. Without it every
# hook is keyless, which is today's workspace-level behaviour.
try:
    import thread_state  # noqa: E402
except ImportError:
    thread_state = None

WORKSPACE_FILENAME = '.flydocs-workspace.json'


def _main_worktree_counterpart(base: Path) -> Path | None:
    if git_worktree is None:
        return None
    return git_worktree.main_worktree_counterpart(base)
ACTIVE_REPO_POINTER = '.flydocs/session/active-repo'


def _read_workspace() -> dict | None:
    """Read .flydocs-workspace.json from CWD if it exists."""
    ws_path = Path(WORKSPACE_FILENAME)
    if not ws_path.exists():
        return None
    try:
        return json.loads(ws_path.read_text())
    except (json.JSONDecodeError, OSError):
        return None


def _repo_name_from_path(file_path: str, workspace: dict) -> str | None:
    """Determine which child repo a file path belongs to.

    Resolves file_path to realpath (resolving symlinks), then checks if it
    falls under any repo directory listed in the workspace file.
    """
    try:
        real_path = os.path.realpath(file_path)
    except (ValueError, OSError):
        return None

    cwd = os.path.realpath(os.getcwd())
    for name, entry in workspace.get('repos', {}).items():
        repo_real = os.path.realpath(os.path.join(cwd, entry.get('path', '')))
        if real_path.startswith(repo_real + os.sep) or real_path == repo_real:
            return name
    return None


def _read_active_repo() -> str | None:
    """Read the repo name from the active-repo pointer file."""
    pointer = Path(ACTIVE_REPO_POINTER)
    if not pointer.exists():
        return None
    try:
        name = pointer.read_text().strip()
        return name if name else None
    except (OSError, IOError):
        return None


def _write_active_repo(repo_name: str) -> None:
    """Write the repo name to the active-repo pointer file."""
    pointer = Path(ACTIVE_REPO_POINTER)
    try:
        pointer.parent.mkdir(parents=True, exist_ok=True)
        pointer.write_text(repo_name + '\n')
    except (OSError, IOError):
        pass


def _repo_dir_from_name(repo_name: str, workspace: dict) -> str | None:
    """Resolve a repo name to its absolute directory path (symlinks resolved)."""
    entry = workspace.get('repos', {}).get(repo_name)
    if not entry:
        return None
    return os.path.realpath(os.path.join(os.getcwd(), entry.get('path', '')))


def list_repo_dirs() -> list[tuple[str, str]]:
    """List all child repo directories in the workspace.

    Returns a list of (name, absolute_path) tuples.
    Returns empty list if not in a sibling-repos workspace.
    """
    workspace = _read_workspace()
    if not workspace:
        return []

    result = []
    for name, entry in workspace.get('repos', {}).items():
        repo_dir = os.path.realpath(os.path.join(os.getcwd(), entry.get('path', '')))
        if os.path.isdir(repo_dir):
            result.append((name, repo_dir))
    return result


def is_workspace() -> bool:
    """Check if CWD is a sibling-repos workspace root."""
    return _read_workspace() is not None


TEMPLATE_MARKER_KEY = 'packagedTemplate'


def is_template_dir(path) -> bool:
    """True if `path` is the packaged template rather than a live repo.

    The template carries the same `.flydocs/` and `.claude/` layout that repo
    resolution looks for, so it reads as a live install and hooks will happily
    write session state into it — shipping one person's session id to every
    customer who installs FlyDocs (FLY-1067).

    The marker is the `packagedTemplate` key inside the root `manifest.json`,
    not the presence of that file (FLY-1144). Presence alone false-positived on
    any repo carrying a root `manifest.json` — a PWA / web app manifest is one
    of the most common files in a web project root — and a false positive here
    makes resolution skip a real root, which is the stray-`.flydocs/` bug
    FLY-1142 fixed.

    Content alone cannot separate the two either: a pre-1.0 install left a
    byte-identical copy of the template manifest at the repo root, so an
    explicit key is the only reliable signal. Nothing in the CLI installs a
    root-level `manifest.json` — `init`/`update` copy scoped subdirectories and
    `integrity.ts` only reads `<target>/.flydocs/manifest.json` — so no
    installed repo can carry the marker, and stale manifests predate the key.
    """
    base = Path(path)
    manifest = base / 'manifest.json'
    if not manifest.is_file():
        return False
    if not (base / '.flydocs' / 'config.json').is_file():
        return False
    try:
        data = json.loads(manifest.read_text(encoding='utf-8'))
    except (OSError, ValueError):
        # Unreadable or not JSON — a customer's file, not our template.
        return False
    return isinstance(data, dict) and data.get(TEMPLATE_MARKER_KEY) is True


def _escape_template(base: Path) -> Path:
    """Walk out of a template directory to the nearest real repo above it."""
    if not is_template_dir(base):
        return base
    for parent in base.resolve().parents:
        if (parent / '.flydocs' / 'config.json').is_file() and \
                not is_template_dir(parent):
            return parent
        if (parent / WORKSPACE_FILENAME).is_file():
            return parent
    return base.resolve().parent


def find_workspace_root(start: str | Path | None = None) -> Path | None:
    """Walk up for `.flydocs-workspace.json` (FLY-1084).

    `_read_workspace()` checks CWD only. Hooks run at the workspace root and
    find it; `issues.py` runs from inside a child repo and does not. That
    disagreement is why the writer (chosen by `cd`) and the reader (chosen by
    the `active-repo` pointer) could land in different directories and drift.

    Worktree-aware (FLY-1715): a linked worktree outside the workspace has no
    workspace file above it, so the walk is repeated from its main worktree.
    """
    if git_worktree is not None:
        return git_worktree.find_workspace_root(start)
    base = Path(start).resolve() if start else Path.cwd().resolve()
    for candidate in (base, *base.parents):
        if (candidate / WORKSPACE_FILENAME).is_file():
            return candidate
    return None


def find_repo_root(start: str | Path | None = None) -> Path | None:
    """Nearest ancestor that is a real FlyDocs root (FLY-1142).

    `resolve_repo_dir()` previously returned bare `os.getcwd()` whenever no
    workspace file sat at the current directory. Anything writing a path from
    that — notably the attribution log — therefore created `.flydocs/` wherever
    it happened to be invoked. Observed: an agent working in
    `flydocs/knowledge/product/` produced
    `flydocs/knowledge/product/.flydocs/session/usage-attribution.jsonl`,
    attribution that no reader looks for.

    A real root is a directory carrying `.flydocs/config.json` (a repo) or the
    workspace file (a multi-repo root). The packaged template is skipped, since
    it carries the same layout without being a live install (FLY-1067).

    A linked worktree with no FlyDocs root of its own — `flydocs worktree init`
    has not run in it yet — resolves through its main worktree (FLY-1715), so
    a fresh T3 or `claude --worktree` checkout reads the config it came from
    instead of falling through to the cwd.
    """
    base = Path(start).resolve() if start else Path.cwd().resolve()
    found = _walk_for_root(base)
    if found is not None:
        return found
    counterpart = _main_worktree_counterpart(base)
    return _walk_for_root(counterpart) if counterpart is not None else None


def _walk_for_root(base: Path) -> Path | None:
    for candidate in (base, *base.parents):
        if is_template_dir(candidate):
            continue
        if (candidate / '.flydocs' / 'config.json').is_file():
            return candidate
        if (candidate / WORKSPACE_FILENAME).is_file():
            return candidate
    return None


# Per-repo session HISTORY, never adopted to the workspace root (FLY-1517).
# `session.py` writes and reads these inside the repo that ran the session;
# a copy at the root is a summary no writer maintains. (`session-start.py`
# still reads summaries through `resolve_session_dir`, i.e. at the root —
# FLY-1624.) Keep the stream spelling in step with `is_stream_file` in
# skills/flydocs-workflow/scripts/session.py.
_PER_REPO_SESSION_FILE_RE = re.compile(
    r'^(last-summary\.json|stream\.jsonl|stream\.\d+\.jsonl)$')

# Active-issue state consumed from the legacy per-repo directory once adopted,
# so the same seed can never be adopted twice (FLY-1517).
_ADOPTED_STATE_FILES = ('focus.md', 'status', 'status-ref',
                        'acceptance-criteria.md')


def _adopt_existing_session(target: Path, workspace_root: Path,
                            scope: str) -> None:
    """Seed workspace-level session state from per-repo state (FLY-1084).

    Existing installs hold active-issue state inside whichever child repo last
    wrote it. Moving the canonical location must not lose the active issue, so
    the most recently modified per-repo directory is adopted once. Most-recent
    wins because it is the only ordering available — the repos genuinely
    disagree, and the newest write is the best evidence of intent.

    Only active-issue state moves: summaries and the record stream stay in
    their repo (FLY-1517).

    Truly one-time (FLY-1517): an existing scope directory, even one a wrap
    has emptied, means the root is already the writer. Treating "empty" as
    "never adopted" re-adopted a stale legacy seed after every wrap. The
    adopted state files are removed from the legacy directory for the same
    reason.
    """
    if target.exists():
        return
    candidates = []
    try:
        for child in workspace_root.iterdir():
            if not child.is_dir():
                continue
            legacy = child / '.flydocs' / 'session' / scope
            focus = legacy / 'focus.md'
            if focus.is_file():
                candidates.append((focus.stat().st_mtime, legacy))
    except OSError:
        return
    if not candidates:
        return
    _, newest = max(candidates, key=lambda pair: pair[0])
    try:
        target.mkdir(parents=True, exist_ok=True)
        for item in newest.iterdir():
            if item.is_file() and \
                    not _PER_REPO_SESSION_FILE_RE.match(item.name):
                (target / item.name).write_bytes(item.read_bytes())
    except OSError:
        return
    for name in _ADOPTED_STATE_FILES:
        try:
            (newest / name).unlink()
        except OSError:
            pass


def resolve_session_dir(repo_dir: str | None = None) -> Path:
    """Resolve the workspace-scoped session directory.

    Active-issue state (focus.md, status, status-ref, acceptance-criteria.md)
    is scoped by workspaceId so that multiple workspaces sharing the same repo directory
    maintain isolated session state. (FLY-674)

    Returns:
        Path to `.flydocs/session/<workspaceId>/` if workspaceId is available,
        `.flydocs/session/default/` otherwise (local tier).

    Never resolves inside the packaged template (FLY-1067).

    Note: The `active-repo` pointer remains at `.flydocs/session/active-repo`
    (flat, unscoped) because it's a workspace-level concern resolved before
    config is available.
    """
    base = Path(repo_dir) if repo_dir else Path('.')
    base = _escape_template(base)
    config_file = base / '.flydocs' / 'config.json'
    if not config_file.exists():
        # FLY-1715: a linked worktree `flydocs worktree init` has not reached
        # yet carries no config; its main checkout's names the same scope.
        counterpart = _main_worktree_counterpart(base)
        if counterpart is not None:
            config_file = counterpart / '.flydocs' / 'config.json'

    workspace_id: str | None = None
    if config_file.exists():
        try:
            config = json.loads(config_file.read_text())
            workspace_id = config.get('workspaceId')
        except (json.JSONDecodeError, OSError):
            pass

    scope = workspace_id if workspace_id else 'default'

    # FLY-1084: in a multi-repo workspace, active-issue state is a workspace
    # concern, not a per-repo one — the same issue is worked across repos in a
    # single session. Anchoring it to the workspace root gives one writer and
    # one reader. Single-repo topologies find no workspace file and are
    # unaffected.
    workspace_root = find_workspace_root(base)
    if workspace_root:
        target = workspace_root / '.flydocs' / 'session' / scope
        _adopt_existing_session(target, workspace_root, scope)
        return target

    return base / '.flydocs' / 'session' / scope


def resolve_repo_dir(file_path: str | None = None) -> str:
    """Resolve the directory to read .flydocs/ files from.

    In sibling-repos topology (workspace file exists at CWD):
    1. If file_path provided, detect repo from the path and update pointer
    2. Fall back to active-repo pointer
    3. Fall back to CWD (workspace root)

    In single-repo topology, returns CWD unchanged.

    Returns an absolute path.
    """
    workspace = _read_workspace()
    if not workspace:
        # Single-repo topology, or a subdirectory of either layout. Resolve to
        # the nearest real FlyDocs root rather than trusting cwd — writing from
        # cwd created stray `.flydocs/` directories in content trees and lost
        # the attribution written into them (FLY-1142). Guard against the
        # packaged template, which carries the same layout (FLY-1067).
        root = find_repo_root()
        if root is not None:
            return str(_escape_template(root))
        return str(_escape_template(Path(os.getcwd())))

    # Option A: detect from file path
    if file_path:
        repo_name = _repo_name_from_path(file_path, workspace)
        if repo_name:
            _write_active_repo(repo_name)
            repo_dir = _repo_dir_from_name(repo_name, workspace)
            if repo_dir:
                return repo_dir

    # Option B: read pointer
    active_name = _read_active_repo()
    if active_name:
        repo_dir = _repo_dir_from_name(active_name, workspace)
        if repo_dir:
            return repo_dir

    return os.getcwd()


def harness_arg(argv: list[str] | None = None) -> str | None:
    """The `--harness <id>` a hook was invoked with, or None.

    `.cursor/hooks.json` passes `--harness cursor`; `.claude/settings.json`
    passes nothing, which means Claude Code.
    """
    import sys
    args = sys.argv[1:] if argv is None else argv
    for i, arg in enumerate(args):
        if arg == '--harness' and i + 1 < len(args):
            return args[i + 1]
        if arg.startswith('--harness='):
            return arg.split('=', 1)[1]
    return None


# Fields only Cursor puts in a hook payload (FLY-1713).
CURSOR_PAYLOAD_KEYS = ('cursor_version', 'conversation_id', 'generation_id',
                       'workspace_roots')


def is_cursor_running_claude_hook(payload: dict | None = None,
                                  argv: list[str] | None = None) -> bool:
    """True when Cursor is running a hook from `.claude/settings.json`.

    FLY-1713: Cursor runs a project's Claude Code hooks as well as its own
    `.cursor/hooks.json`, and sets `CLAUDE_PROJECT_DIR` while it does
    (verified under T3 with the Cursor SDK, FLY-1711 C3). A hook wired in both
    files therefore fired twice per Cursor event: two attribution tuples per
    prompt, two auto-approvals per command. The Cursor copy carries
    `--harness cursor`, so the Claude copy is the one to drop.

    Cursor is recognized by markers in the payload it sends, never by
    environment: it sets `CLAUDE_*` too, and a Claude Code session running in
    Cursor's integrated terminal inherits Cursor's variables (`CURSOR_*`), so
    an env check would silence every hook of a real Claude session. The
    payload is per-invocation: Cursor's own fields (`cursor_version`,
    `conversation_id`, `generation_id`, `workspace_roots`) or a session id in
    Cursor's `agent-…` form where Claude puts a UUID.
    """
    if harness_arg(argv) is not None:
        return False
    payload = payload or {}
    if any(payload.get(key) for key in CURSOR_PAYLOAD_KEYS):
        return True
    return str(payload.get('session_id', '')).startswith('agent-')


def thread_key(payload: dict | None = None,
               argv: list[str] | None = None,
               session_dir=None):
    """The thread a hook event belongs to, or None (FLY-1714).

    None means keyless: no session id in the payload or the environment, the
    shared module is missing, or — when `session_dir` is given — the harness
    has no keyed writers and no thread file of its own (Cursor: its keyed hooks
    read what its keyless MCP and shell wrote, the workspace-level files, as
    its own). A keyless hook reads and writes those files exactly as before
    per-thread state existed. Pass `session_dir` from every READING hook.
    """
    if thread_state is None:
        return None
    try:
        key = thread_state.resolve_key(payload, argv)
        if session_dir is not None:
            key = thread_state.effective_key(session_dir, key)
        return key
    except Exception:
        return None


def is_background_turn(payload: dict | None) -> bool:
    """True for a host's own housekeeping turn (T3's thread-title run), which
    must leave no thread state behind."""
    if thread_state is None:
        return False
    try:
        return thread_state.is_background_turn(payload)
    except Exception:
        return False
