"""
Static analysis linter for OntoSkills ontologies.

Analyses the compiled ontology (or a set of .ttl files) without calling the
Anthropic API and reports structural issues:

  - dead_states      : a skill requiresState X but no skill yieldsState X
  - circular_deps    : A dependsOn B dependsOn ... dependsOn A
  - duplicate_intents: two different skills resolve the same intent string
  - unreachable_skills: skill with requiresState but no skill yields those states
                        and the skill has no entry-point intents

Phase 1 source linting (before compilation):
  - third-person     : description should be in third person
  - skill-md-length  : SKILL.md body should be under 500 lines
  - reference-depth  : reference files should be one level deep from SKILL.md

Each issue is returned as a LintIssue dataclass so callers can format the
output however they like (Rich terminal, JSON, CI log).
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal, TYPE_CHECKING

from rdflib import Graph, Namespace
from rdflib.namespace import DCTERMS

if TYPE_CHECKING:
    from compiler.schemas import DirectoryScan

OC = Namespace("https://ontoskills.sh/ontology#")

Severity = Literal["error", "warning", "info"]


@dataclass
class LintIssue:
    severity: Severity
    code: str          # e.g. "dead-state", "circular-dep"
    skill_id: str
    message: str
    detail: str = ""


@dataclass
class LintResult:
    issues: list[LintIssue] = field(default_factory=list)

    @property
    def errors(self) -> list[LintIssue]:
        return [i for i in self.issues if i.severity == "error"]

    @property
    def warnings(self) -> list[LintIssue]:
        return [i for i in self.issues if i.severity == "warning"]

    @property
    def has_errors(self) -> bool:
        return bool(self.errors)

    @property
    def is_clean(self) -> bool:
        return not self.issues


def lint_ontology(ttl_path: str | Path) -> LintResult:
    """
    Run all lint checks against a compiled .ttl file.

    Args:
        ttl_path: Path to the ontology file (index.ttl or any skill .ttl).

    Returns:
        LintResult containing all discovered issues.
    """
    g = Graph()
    g.parse(str(ttl_path), format="turtle")

    result = LintResult()
    result.issues += _check_dead_states(g)
    result.issues += _check_circular_deps(g)
    result.issues += _check_duplicate_intents(g)
    result.issues += _check_unreachable_skills(g)
    result.issues += _check_workflow_cycles(g)
    return result


# ─── Private checks ───────────────────────────────────────────────────────────


def _all_skills(g: Graph) -> list[str]:
    """Return local names of all skills that declare at least one intent."""
    return [_local(s) for s in g.subjects(OC.resolvesIntent)]


def _local(uri) -> str:
    return str(uri).split("#")[-1].split("/")[-1]


def _check_dead_states(g: Graph) -> list[LintIssue]:
    """
    Detect states that are required by some skill but never yielded by any skill.

    A dead state makes a skill permanently un-executable: the precondition can
    never be satisfied because no skill produces it.
    """
    required = {str(o) for o in g.objects(predicate=OC.requiresState)}
    yielded  = {str(o) for o in g.objects(predicate=OC.yieldsState)}
    dead     = required - yielded

    issues: list[LintIssue] = []
    for state in dead:
        # Find which skills need this state
        for skill_uri in g.subjects(OC.requiresState, g.store.__class__):
            pass
        affected = [
            _local(s)
            for s in g.subjects(OC.requiresState)
            if str(next(g.objects(s, OC.requiresState), None)) == state
            or state in {str(o) for o in g.objects(s, OC.requiresState)}
        ]
        for skill_id in affected:
            issues.append(LintIssue(
                severity="warning",
                code="dead-state",
                skill_id=skill_id,
                message=f"requiresState '{_local(state)}' is never yielded by any skill",
                detail="This skill may be permanently un-executable in a cold-start scenario.",
            ))
    return issues


def _check_circular_deps(g: Graph) -> list[LintIssue]:
    """
    Detect circular dependency chains via oc:dependsOn.

    Uses DFS with a recursion stack. Reports each skill that is part of a cycle.
    """
    # Build adjacency: skill_id → set of skill_ids it depends on
    adj: dict[str, set[str]] = {}
    for s, o in g.subject_objects(OC.dependsOn):
        sid = _local(s)
        oid = _local(o)
        adj.setdefault(sid, set()).add(oid)

    visited:   set[str] = set()
    rec_stack: set[str] = set()
    cycles:    set[str] = set()

    def dfs(node: str) -> bool:
        visited.add(node)
        rec_stack.add(node)
        for neighbour in adj.get(node, set()):
            if neighbour not in visited:
                if dfs(neighbour):
                    cycles.add(node)
                    return True
            elif neighbour in rec_stack:
                cycles.add(node)
                cycles.add(neighbour)
                return True
        rec_stack.discard(node)
        return False

    for skill in list(adj.keys()):
        if skill not in visited:
            dfs(skill)

    return [
        LintIssue(
            severity="error",
            code="circular-dep",
            skill_id=sid,
            message=f"Circular dependency detected involving '{sid}'",
            detail="Resolve the cycle in oc:dependsOn before deploying.",
        )
        for sid in sorted(cycles)
    ]


def _check_duplicate_intents(g: Graph) -> list[LintIssue]:
    """
    Detect intent strings declared by more than one skill.

    Duplicate intents cause non-deterministic skill selection at runtime:
    the agent cannot know which skill to invoke for a given intent.
    """
    intent_map: dict[str, list[str]] = {}
    for s, o in g.subject_objects(OC.resolvesIntent):
        intent = str(o)
        intent_map.setdefault(intent, []).append(_local(s))

    issues: list[LintIssue] = []
    for intent, skills in intent_map.items():
        if len(skills) > 1:
            for sid in skills:
                issues.append(LintIssue(
                    severity="error",
                    code="duplicate-intent",
                    skill_id=sid,
                    message=f"Intent '{intent}' is also declared by: {', '.join(s for s in skills if s != sid)}",
                    detail="Non-deterministic skill selection at runtime.",
                ))
    return issues


def _check_unreachable_skills(g: Graph) -> list[LintIssue]:
    """
    Detect skills that have preconditions (requiresState) but those states
    are never yielded by any skill AND the skill doesn't look like an entry point.

    Entry point indicators:
    - No requiresState (can run from cold start)
    - Has intents that suggest user-initiated actions (create, import, load, etc.)
    """
    # Deduplicate URIs (skills with multiple intents appear multiple times in g.subjects)
    all_skill_uris = list(dict.fromkeys(g.subjects(OC.resolvesIntent)))

    # All states that can be produced
    all_yielded = {str(o) for o in g.objects(predicate=OC.yieldsState)}

    # Entry point intent patterns (skills that start workflows)
    entry_patterns = {'create', 'import', 'load', 'init', 'start', 'open', 'new'}

    issues: list[LintIssue] = []
    for skill_uri in all_skill_uris:
        # Prefer canonical dcterms:identifier when available, fall back to local fragment
        identifier_obj = next(g.objects(skill_uri, DCTERMS.identifier), None)
        sid = str(identifier_obj) if identifier_obj is not None else _local(skill_uri)

        required = {str(o) for o in g.objects(skill_uri, OC.requiresState)}
        intents = [str(o).lower() for o in g.objects(skill_uri, OC.resolvesIntent)]

        # Check if any required state is unreachable
        unreachable = required - all_yielded

        if unreachable:
            # Check if this looks like an entry point
            is_entry = any(
                any(pattern in intent for pattern in entry_patterns)
                for intent in intents
            )

            if not is_entry:
                for state in unreachable:
                    issues.append(LintIssue(
                        severity="warning",
                        code="unreachable-state",
                        skill_id=sid,
                        message=f"requiresState '{_local(state)}' is never yielded by any skill",
                        detail="This skill may be unreachable unless the state is externally provided.",
                    ))

    return issues


def _check_workflow_cycles(g: Graph) -> list[LintIssue]:
    """
    Detect circular dependencies in workflow steps.

    A workflow step that depends on itself (directly or transitively)
    creates an impossible execution order.
    """
    issues: list[LintIssue] = []

    # Deduplicate skill URIs (subjects yields once per triple)
    for skill_uri in dict.fromkeys(g.subjects(OC.hasWorkflow)):
        # Get skill ID for reporting
        identifier_obj = next(g.objects(skill_uri, DCTERMS.identifier), None)
        skill_id = str(identifier_obj) if identifier_obj else _local(skill_uri)

        for workflow in g.objects(skill_uri, OC.hasWorkflow):
            # Build dependency graph: step_id -> set of dependency step_ids
            step_deps: dict[str, set[str]] = {}

            for step in g.objects(workflow, OC.hasStep):
                step_id_obj = next(g.objects(step, OC.stepId), None)
                if step_id_obj:
                    step_id = str(step_id_obj)
                    dep_ids: set[str] = set()
                    # New workflow model: dependencies as node references via oc:stepDependsOn.
                    # Resolve each referenced step node back to its oc:stepId literal.
                    for dep_step in g.objects(step, OC.stepDependsOn):
                        dep_step_id_obj = next(g.objects(dep_step, OC.stepId), None)
                        if dep_step_id_obj:
                            dep_ids.add(str(dep_step_id_obj))
                    # Backward compatibility: also honor literal step-id dependencies via oc:dependsOn.
                    for dep_literal in g.objects(step, OC.dependsOn):
                        dep_ids.add(str(dep_literal))
                    step_deps[step_id] = dep_ids

            # Detect cycles via DFS
            visited: set[str] = set()
            rec_stack: set[str] = set()

            def dfs(node: str, path: list[str]) -> bool:
                visited.add(node)
                rec_stack.add(node)
                path.append(node)

                try:
                    for neighbor in step_deps.get(node, set()):
                        if neighbor not in visited:
                            dfs(neighbor, path)  # Continue to find all cycles
                        elif neighbor in rec_stack:
                            # Found cycle
                            cycle_start = path.index(neighbor) if neighbor in path else 0
                            cycle = path[cycle_start:] + [neighbor]
                            issues.append(LintIssue(
                                severity="error",
                                code="workflow-cycle",
                                skill_id=skill_id,
                                message=f"Circular dependency in workflow: {' -> '.join(cycle)}",
                                detail=f"Step '{neighbor}' creates a dependency cycle.",
                            ))
                    return False
                finally:
                    path.pop()
                    rec_stack.discard(node)

            for step_id in step_deps:
                if step_id not in visited:
                    dfs(step_id, [])

    return issues


# ─── Phase 1 Source Linting ────────────────────────────────────────────────────


# Non-third-person language patterns to detect in descriptions
# Includes first-person (I, me, my, we, our) and second-person (You, Your)
NON_THIRD_PERSON_PATTERNS = [
    r'\bI\b',           # "I can help"
    r'\bI\'m\b',        # "I'm able to"
    r'\bI\'ll\b',       # "I'll process"
    r'\bI\'ve\b',       # "I've created"
    r'\bme\b',          # "helps me"
    r'\bmy\b',          # "my skills"
    r'\bmine\b',        # "this is mine"
    r'\bwe\b',          # "we can"
    r'\bour\b',         # "our approach"
    r'\bYou can\b',     # "You can use this"
    r'\bYou should\b',  # "You should"
    r'\bYour\b',        # "Your data"
]

# Maximum recommended lines for SKILL.md body
MAX_SKILL_MD_LINES = 500


def lint_skill_source(dir_scan: "DirectoryScan") -> LintResult:
    """
    Lint Phase 1 source data before compilation.

    Checks:
    - third-person: Description should be in third person
    - skill-md-length: SKILL.md body should be under 500 lines
    - reference-depth: Reference files should be one level deep

    Args:
        dir_scan: DirectoryScan from Phase 1 loader

    Returns:
        LintResult with any issues found
    """
    result = LintResult()
    skill_id = dir_scan.skill_id

    # Check description for first-person language
    result.issues.extend(_check_third_person(dir_scan.frontmatter.description, skill_id))

    # Check SKILL.md body length
    result.issues.extend(_check_skill_md_length(dir_scan.skill_md_content, skill_id))

    # Check reference file depth
    result.issues.extend(_check_reference_depth(dir_scan.files, skill_id))

    return result


def _check_third_person(description: str, skill_id: str) -> list[LintIssue]:
    """
    Check if description uses first-person language.

    Anthropic best practice: descriptions should be in third person
    because they are injected into the system prompt.
    """
    issues: list[LintIssue] = []
    matches_found: list[str] = []

    for pattern in NON_THIRD_PERSON_PATTERNS:
        if re.search(pattern, description, re.IGNORECASE):
            # Extract the matched text for context
            match = re.search(pattern, description, re.IGNORECASE)
            if match:
                matches_found.append(match.group())

    if matches_found:
        issues.append(LintIssue(
            severity="warning",
            code="third-person",
            skill_id=skill_id,
            message=f"Description contains non-third-person language: {', '.join(set(matches_found))}",
            detail="Use third person (e.g., 'Processes files' instead of 'I process files'). "
                   "Descriptions are injected into the system prompt.",
        ))

    return issues


def _check_skill_md_length(content: str, skill_id: str) -> list[LintIssue]:
    """
    Check if SKILL.md body exceeds recommended length.

    Anthropic best practice: keep SKILL.md under 500 lines for optimal performance.
    """
    issues: list[LintIssue] = []

    # Count lines (excluding frontmatter)
    # Find end of frontmatter
    lines = content.split('\n')
    body_start = 0

    # Skip frontmatter (between --- delimiters)
    if lines and lines[0].strip() == '---':
        for i, line in enumerate(lines[1:], start=1):
            if line.strip() == '---':
                body_start = i + 1
                break

    body_lines = len(lines) - body_start

    if body_lines > MAX_SKILL_MD_LINES:
        issues.append(LintIssue(
            severity="warning",
            code="skill-md-length",
            skill_id=skill_id,
            message=f"SKILL.md body has {body_lines} lines (recommended max: {MAX_SKILL_MD_LINES})",
            detail="Consider splitting content into separate reference files using progressive disclosure.",
        ))

    return issues


def _check_reference_depth(files: list, skill_id: str) -> list[LintIssue]:
    """
    Check if reference files (markdown) are more than one level deep.

    Anthropic best practice: keep references one level deep from SKILL.md
    to ensure Claude reads complete files when needed.

    Note: This only checks markdown files, as they are the typical reference format.
    Scripts and assets may legitimately be in deeper subdirectories.
    """
    issues: list[LintIssue] = []

    for file_info in files:
        rel_path = file_info.relative_path
        # Skip SKILL.md itself
        if rel_path == "SKILL.md":
            continue

        # Only check markdown files (reference files)
        if not rel_path.endswith('.md'):
            continue

        # Count directory depth (number of path separators)
        depth = rel_path.count('/')

        if depth > 1:
            issues.append(LintIssue(
                severity="info",
                code="reference-depth",
                skill_id=skill_id,
                message=f"Reference file '{rel_path}' is {depth} levels deep (recommended: 1)",
                detail="Deep references may be partially read. Consider flattening structure.",
            ))

    return issues
