"""프로젝트가 소유한 경로에서 태스크·런 참조를 해석한다.

여기 있는 것은 전부 "이 참조가 가리키는 파일이 어디인가"에 답한다 — 태스크 키,
카탈로그 항목, `latest` 포인터, 런 매니페스트. 소유 경로 밖으로 나가는 값은
`JsonBoundaryError` 로 거절한다. 파일 시스템을 보는 코드는 이 층에만 있다.
"""
from __future__ import annotations

import os
from pathlib import Path
from typing import Any, Mapping

from okstra_project import StateError, parse_task_key, slugify

from ..json_boundary import JsonBoundaryError, load_owned_object
from ..paths import okstra_home


def _fixed_owned_artifact_path(
    project_root: Path, relative_path: Path, artifact: str
) -> Path:
    resolved_project_root = project_root.resolve()
    okstra_root = resolved_project_root / ".okstra"
    if okstra_root.is_symlink():
        raise JsonBoundaryError(okstra_root, artifact, "Okstra root is not a fixed directory")
    resolved_okstra_root = okstra_root.resolve()
    try:
        resolved_okstra_root.relative_to(resolved_project_root)
    except ValueError as exc:
        raise JsonBoundaryError(okstra_root, artifact, "outside this project") from exc
    path = okstra_root / relative_path
    expected_path = resolved_okstra_root / relative_path
    if path.resolve() != expected_path:
        raise JsonBoundaryError(path, artifact, "not at its fixed Okstra path")
    return path


def _owned_tasks_root(project_root: Path, artifact: str) -> Path:
    path = _fixed_owned_artifact_path(project_root, Path("tasks"), artifact)
    if not path.is_dir():
        raise JsonBoundaryError(path, artifact, "tasks root is not a directory")
    return path


def _load_project(project_root: Path) -> dict[str, Any]:
    path = _fixed_owned_artifact_path(
        project_root, Path("project.json"), "project metadata"
    )
    return load_owned_object(path, artifact="project metadata")


def _project_relative(project_root: Path, path: Path) -> str:
    try:
        return str(path.resolve().relative_to(project_root.resolve()))
    except ValueError:
        return str(path)


def _owned_project_path(project_root: Path, value: str, artifact: str) -> Path:
    path = Path(value)
    path = path if path.is_absolute() else project_root / path
    try:
        path.resolve().relative_to(project_root.resolve())
    except ValueError as exc:
        raise JsonBoundaryError(path, artifact, "outside this project") from exc
    return path


def _task_reference_error(project_root: Path, reason: str) -> JsonBoundaryError:
    return JsonBoundaryError(project_root, "task reference", reason)


def _catalog_tasks(project_root: Path) -> list[Mapping[str, Any]]:
    path = _fixed_owned_artifact_path(
        project_root, Path("discovery") / "task-catalog.json", "task catalog"
    )
    if not path.is_file():
        return []
    catalog = load_owned_object(path, artifact="task catalog")
    tasks = catalog.get("tasks")
    return [task for task in tasks if isinstance(task, Mapping)] if isinstance(tasks, list) else []


def _catalog_task_root(project_root: Path, task: Mapping[str, Any]) -> Path | None:
    value = task.get("taskRootPath") or task.get("taskRoot")
    if not isinstance(value, str) or not value:
        return None
    path = Path(value)
    path = path if path.is_absolute() else project_root / path
    return path if path.is_dir() else None


def _normal_task_root(project_root: Path, task_key: str) -> Path | None:
    _, task_group, task_id = parse_task_key(task_key)
    path = _owned_tasks_root(project_root, "task reference") / slugify(task_group) / slugify(task_id)
    return path if path.is_dir() else None


def _catalog_task_reference(
    project_root: Path, reference: str
) -> tuple[Path | None, str | None]:
    tasks = _catalog_tasks(project_root)
    if ":" in reference:
        parse_task_key(reference)
        matches = [
            task for task in tasks
            if isinstance(task.get("taskKey"), str)
            and task["taskKey"].lower() == reference.lower()
        ]
        task_key = matches[0]["taskKey"] if matches else reference
        task_root = _catalog_task_root(project_root, matches[0]) if matches else None
        return task_root or _normal_task_root(project_root, task_key), task_key

    matches = [
        task for task in tasks
        if isinstance(task.get("taskId"), str)
        and task["taskId"].lower() == reference.lower()
    ]
    if len(matches) != 1:
        raise _task_reference_error(
            project_root, "task reference must resolve to exactly one task"
        )
    task_key = matches[0].get("taskKey")
    if not isinstance(task_key, str):
        return None, None
    return _catalog_task_root(project_root, matches[0]) or _normal_task_root(
        project_root, task_key
    ), task_key


def _owned_task_manifest_path(
    project_root: Path, value: object, artifact: str
) -> Path:
    if not isinstance(value, str) or not value:
        raise JsonBoundaryError(project_root, artifact, "task manifest path is missing")
    path = Path(value)
    path = path if path.is_absolute() else project_root / path
    if path.name != "task-manifest.json":
        raise JsonBoundaryError(path, artifact, "task manifest path is invalid")
    tasks_root = _owned_tasks_root(project_root, artifact)
    try:
        path.resolve().relative_to(tasks_root.resolve())
    except ValueError as exc:
        raise JsonBoundaryError(path, artifact, "task manifest is outside this project") from exc
    return path


def _selected_run_manifest_path(
    project_root: Path, task_root: Path, value: str
) -> Path:
    path = Path(value)
    path = path if path.is_absolute() else project_root / path
    tasks_root = _owned_tasks_root(project_root, "run manifest")
    resolved_task_root = task_root.resolve()
    resolved_runs_root = (task_root / "runs").resolve()
    try:
        resolved_task_root.relative_to(tasks_root.resolve())
        resolved_runs_root.relative_to(resolved_task_root)
        path.resolve().relative_to(resolved_runs_root)
        path.resolve().relative_to(resolved_task_root)
    except ValueError as exc:
        raise JsonBoundaryError(path, "run manifest", "outside selected task") from exc
    if not path.name.startswith("run-manifest-") or path.suffix != ".json":
        raise JsonBoundaryError(path, "run manifest", "not a normal run manifest path")
    return path


def _owned_run_manifest_path(run_manifest: Path) -> Path:
    for runs_root in run_manifest.parents:
        if runs_root.name != "runs":
            continue
        task_root = runs_root.parent
        tasks_root = task_root.parent.parent
        if tasks_root.name != "tasks" or tasks_root.parent.name != ".okstra":
            continue
        project_root = tasks_root.parent.parent
        return _selected_run_manifest_path(project_root, task_root, str(run_manifest))
    raise JsonBoundaryError(
        run_manifest, "run manifest", "outside owned task runs"
    )


def _task_manifest_from_reference(project_root: Path, task_ref: str) -> tuple[Path, str | None]:
    reference = task_ref.strip()
    candidate = Path(reference).expanduser()
    candidate = candidate if candidate.is_absolute() else project_root / candidate
    if candidate.is_file():
        try:
            return (
                _owned_task_manifest_path(project_root, str(candidate), "task reference"),
                None,
            )
        except JsonBoundaryError as exc:
            raise _task_reference_error(project_root, exc.reason) from exc
    try:
        task_root, task_key = _catalog_task_reference(project_root, reference)
    except StateError as exc:
        raise _task_reference_error(project_root, str(exc)) from exc
    if task_root is None:
        raise _task_reference_error(project_root, "task not found")
    try:
        manifest_path = _owned_task_manifest_path(
            project_root, str(task_root / "task-manifest.json"), "task reference"
        )
    except JsonBoundaryError as exc:
        raise _task_reference_error(project_root, exc.reason) from exc
    return manifest_path, task_key if isinstance(task_key, str) else None


def _task_pointer_from_manifest(
    project_root: Path,
    manifest_path: Path,
    *,
    expected_task_key: object = None,
    pointer_run_manifest: object = None,
    artifact: str,
) -> Mapping[str, Any]:
    manifest = load_owned_object(manifest_path, artifact="task manifest")
    task_key = manifest.get("taskKey")
    if not isinstance(task_key, str):
        raise JsonBoundaryError(manifest_path, artifact, "task key is invalid")
    try:
        _, task_group, task_id = parse_task_key(task_key)
    except StateError as exc:
        raise JsonBoundaryError(manifest_path, artifact, "task key is invalid") from exc
    normal_manifest_path = (
        _owned_tasks_root(project_root, artifact)
        / slugify(task_group)
        / slugify(task_id)
        / "task-manifest.json"
    )
    if manifest_path.resolve() != normal_manifest_path.resolve():
        raise JsonBoundaryError(
            manifest_path, artifact, "task key does not match task manifest path"
        )
    if expected_task_key is not None and task_key != expected_task_key:
        raise JsonBoundaryError(manifest_path, artifact, "task key does not match task manifest")
    task_root = manifest_path.parent
    expected_timeline_path = task_root / "history" / "timeline.json"
    try:
        expected_timeline_path.resolve().relative_to(task_root.resolve())
    except ValueError as exc:
        raise JsonBoundaryError(
            expected_timeline_path, "task timeline", "outside selected task"
        ) from exc
    timeline_value = manifest.get("historyTimelinePath")
    if isinstance(timeline_value, str) and timeline_value:
        timeline_path = _owned_project_path(
            project_root, timeline_value, "task timeline"
        )
        if timeline_path.resolve() != expected_timeline_path.resolve():
            raise JsonBoundaryError(
                timeline_path, "task timeline", "outside selected task"
            )
    else:
        timeline_path = expected_timeline_path
    latest_run_manifest: object = None
    latest_run_path: Path | None = None
    if timeline_path.is_file():
        timeline = load_owned_object(timeline_path, artifact="task timeline")
        runs = timeline.get("runs")
        if isinstance(runs, list):
            for run in reversed(runs):
                if isinstance(run, Mapping) and isinstance(run.get("runManifestPath"), str):
                    latest_run_path = _selected_run_manifest_path(
                        project_root, task_root, run["runManifestPath"]
                    )
                    latest_run_manifest = _project_relative(project_root, latest_run_path)
                    break
    if pointer_run_manifest is not None:
        if not isinstance(pointer_run_manifest, str):
            raise JsonBoundaryError(manifest_path, artifact, "run manifest path is invalid")
        pointer_run_path = _selected_run_manifest_path(
            project_root, task_root, pointer_run_manifest
        )
        if latest_run_path is None or pointer_run_path.resolve() != latest_run_path.resolve():
            raise JsonBoundaryError(
                manifest_path, artifact, "run manifest does not match task timeline"
            )
    return {
        "taskKey": task_key,
        "taskManifestPath": _project_relative(project_root, manifest_path),
        "latestRunManifestPath": latest_run_manifest,
    }


def _latest_task_pointer(project_root: Path) -> Mapping[str, Any]:
    path = _fixed_owned_artifact_path(
        project_root, Path("discovery") / "latest-task.json", "latest task pointer"
    )
    if not path.is_file():
        return {}
    pointer = load_owned_object(path, artifact="latest task pointer")
    manifest_path = _owned_task_manifest_path(
        project_root, pointer.get("taskManifestPath"), "latest task pointer"
    )
    return _task_pointer_from_manifest(
        project_root,
        manifest_path,
        expected_task_key=pointer.get("taskKey"),
        pointer_run_manifest=pointer.get("latestRunManifestPath"),
        artifact="latest task pointer",
    )


def _selected_task_pointer(project_root: Path, task_ref: str) -> Mapping[str, Any]:
    manifest_path, expected_task_key = _task_manifest_from_reference(project_root, task_ref)
    return _task_pointer_from_manifest(
        project_root,
        manifest_path,
        expected_task_key=expected_task_key,
        artifact="task reference",
    )


def _group_segment(value: str) -> str:
    return "".join(character for character in value.lower() if character.isalnum())


def _catalog_task_path(project_root: Path, task: Mapping[str, Any]) -> tuple[Path, str]:
    task_key = task.get("taskKey")
    if not isinstance(task_key, str):
        raise JsonBoundaryError(project_root, "task catalog", "task key is invalid")
    try:
        _, task_group, task_id = parse_task_key(task_key)
    except StateError as exc:
        raise JsonBoundaryError(project_root, "task catalog", "task key is invalid") from exc
    expected_group_segment = slugify(task_group)
    expected_task_segment = slugify(task_id)
    group_segment = task.get("taskGroupPathSegment", expected_group_segment)
    task_segment = task.get("taskIdPathSegment", expected_task_segment)
    if group_segment != expected_group_segment or task_segment != expected_task_segment:
        raise JsonBoundaryError(
            project_root, "task catalog", "task path segment does not match task key"
        )
    path = (
        _owned_tasks_root(project_root, "task catalog")
        / expected_group_segment
        / expected_task_segment
        / "task-manifest.json"
    )
    return _owned_task_manifest_path(project_root, str(path), "task catalog"), task_key


def _selected_task_data(
    project_root: Path, task_ref: str
) -> tuple[Mapping[str, Any], list[Mapping[str, Any]]]:
    manifest_path, expected_key = _task_manifest_from_reference(project_root, task_ref)
    _task_pointer_from_manifest(
        project_root, manifest_path, expected_task_key=expected_key, artifact="task reference"
    )
    manifest = load_owned_object(manifest_path, artifact="task manifest")
    timeline_path = manifest_path.parent / "history" / "timeline.json"
    timeline = (
        load_owned_object(timeline_path, artifact="task timeline")
        if timeline_path.is_file()
        else {}
    )
    runs = timeline.get("runs")
    return manifest, [run for run in runs if isinstance(run, Mapping)] if isinstance(runs, list) else []


def _symlink_ancestor(path: Path) -> Path | None:
    lexical = Path(os.path.abspath(path))
    current = Path(lexical.anchor)
    for part in lexical.parts[1:-1]:
        current /= part
        if current.is_symlink():
            return current
    return None
