"""
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
from pathlib import Path

WORKSPACE_FILENAME = '.flydocs-workspace.json'
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.
    """
    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).
    """
    base = Path(start).resolve() if start else Path.cwd().resolve()
    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


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.
    """
    if target.exists() and any(target.iterdir()):
        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():
                (target / item.name).write_bytes(item.read_bytes())
    except OSError:
        pass


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

    Session files (focus.md, status, status-ref, last-summary.json) are 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'

    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()
