"""Apply reviewed reference routes to a release candidate, retaining every conflict.

This never edits the translation harness's source-numbered output. An audit is
bound to a complete input snapshot; no lexical similarity score authorizes edits.
"""
from __future__ import annotations

import hashlib
import json
import re
from collections import Counter, defaultdict
from pathlib import Path

from src.output.originals import load_site


def digest(value):
    return hashlib.sha256(value.encode()).hexdigest()


def read_output(directory):
    records, files = {}, {}
    for path in sorted(Path(directory).glob('*/chapter_*.json')):
        content = path.read_bytes()
        files[str(path.relative_to(directory))] = hashlib.sha256(content).hexdigest()
        data = json.loads(content)
        for verse, record in data['verses'].items():
            key = f'{path.parent.name}:{data["chapter"]}:{int(verse)}'
            if key in records or not record['text'].strip():
                raise ValueError(f'Duplicate or empty translation: {key}')
            records[key] = record['text']
    if not records:
        raise ValueError('No translations found')
    return records, files


def ref_order(ref):
    book, ch, verse = ref.split(':')
    return book, int(ch), int(verse)


def candidate_hashes(choices):
    return sorted(({'source': item['source'], 'text_sha256': digest(item['text'])}
                   for item in choices), key=lambda item: ref_order(item['source']))


def validate_repair(target, repair, choices):
    if repair['expected_candidates'] != candidate_hashes(choices):
        raise ValueError(f'Repair candidates changed: {target}')
    text = repair['text']
    if not isinstance(text, str) or not text.strip() or re.search(r'[\x00-\x1f\x7f<>\ufffd]|```', text):
        raise ValueError(f'Invalid repaired text: {target}')
    if not repair.get('reason') or not repair.get('evidence_sha256') or not repair.get('verified_by'):
        raise ValueError(f'Repair lacks review evidence: {target}')
    return text


def route_record(source, text, review):
    """Return lossless character slices, or hold a record requiring more review."""
    if review is None:
        return [(source, text)]
    if review['text_sha256'] != digest(text):
        raise ValueError(f'Reviewed text changed: {source}')
    routes = review.get('routes')
    if routes is None:
        decision = review['decision']
        if decision == 'same':
            return [(source, text)]
        if decision == 'mapped' and len(review['mapped_targets']) == 1:
            return [(review['mapped_targets'][0], text)]
        if decision not in ('mapped', 'mixed', 'unclear'):
            raise ValueError(f'Unknown review decision: {source}')
        return []
    cursor, result = 0, []
    for route in routes:
        start, end = route['start'], route['end']
        if start != cursor or not start < end <= len(text):
            raise ValueError(f'Character loss or duplication: {source}')
        result.append((route['target'], text[start:end]))
        cursor = end
    if cursor != len(text):
        raise ValueError(f'Unassigned characters: {source}')
    return result


def align(records, site_refs, review):
    candidates, held = defaultdict(list), {}
    reviewed = review['records']
    if reviewed.keys() - records.keys():
        raise ValueError('Review contains unknown source references')
    for source, text in records.items():
        routes = route_record(source, text, reviewed.get(source))
        if not routes:
            held[source] = {'text': text, 'review': reviewed[source]}
        for target, part in routes:
            book, ch, verse = ref_order(target)
            if target not in site_refs and not (verse == 0 and f'{book}:{ch}:1' in site_refs):
                raise ValueError(f'Route outside site: {source} -> {target}')
            candidates[target].append({'source': source, 'text': part})
    resolved, conflicts, dispositions = {}, {}, {}
    repairs = review.get('target_repairs', {})
    for target in repairs:
        if target not in site_refs:
            raise ValueError(f'Repair outside site: {target}')
        candidates.setdefault(target, [])
    resolutions = review.get('resolutions', {})
    if resolutions.keys() - candidates.keys():
        raise ValueError('Resolution has no candidates')
    for target, choices in sorted(candidates.items(), key=lambda item: ref_order(item[0])):
        choices.sort(key=lambda item: ref_order(item['source']))
        if target in repairs:
            repair = repairs[target]
            text = validate_repair(target, repair, choices)
            dispositions[target] = {'reason': repair['reason'], 'all_candidates': choices,
                                    'repair': repair}
            resolved[target] = {'text': text, 'sources': [item['source'] for item in choices],
                                'restored': not choices, 'reviewed_repair': True}
            continue
        resolution = resolutions.get(target)
        if resolution:
            actual = [item['source'] for item in choices]
            if sorted(actual) != sorted(resolution['expected_candidates']):
                raise ValueError(f'Resolution candidates changed: {target}')
            by_source = {item['source']: item for item in choices}
            selected = resolution['sources']
            if len(set(actual)) != len(actual) or not selected or len(set(selected)) != len(selected) or set(selected) - by_source.keys():
                raise ValueError(f'Invalid selection: {target}')
            parts = [by_source[source] for source in selected]
            dispositions[target] = {'reason': resolution['reason'], 'selected': selected, 'all_candidates': choices}
        elif target.endswith(':0'):
            parts = choices  # Consecutive, separately numbered superscription lines.
        elif len({item['text'] for item in choices}) == 1:
            parts = choices[:1]
            if len(choices) > 1:
                dispositions[target] = {'reason': 'Identical text at the same destination', 'selected': [parts[0]['source']], 'all_candidates': choices}
        else:
            conflicts[target] = choices
            continue
        resolved[target] = {'text': ''.join(item['text'] for item in parts), 'sources': [item['source'] for item in parts]}
    missing = sorted(site_refs - candidates.keys(), key=ref_order)
    extras = {}
    for source in sorted(records.keys() - site_refs, key=ref_order):
        targets = [t for t, choices in candidates.items() if any(item['source'] == source for item in choices)]
        extras[source] = {'text': records[source], 'targets': targets,
                          'basis': reviewed[source]['basis'], 'decision': reviewed[source]['decision'],
                          'status': 'held' if source in held else ('conflict' if any(t in conflicts for t in targets) else 'resolved'),
                          'destinations': {t: {'candidates': candidates[t], 'selected': resolved.get(t)} for t in targets}}
    changes = {key: value for key, value in resolved.items() if value['sources'] != [key] or value['text'] != records.get(key)}
    return {'schema': 1, 'translation_name': '日常訳第1版',
            'numbering_ready': not (missing or conflicts or held),
            'summary': {'source_records': len(records), 'site_references': len(site_refs),
                        'reviewed_sensitive_records': len(reviewed), 'reviewers': dict(Counter(r['reviewer'] for r in reviewed.values())),
                        'resolved_site_references': len(site_refs & resolved.keys()), 'aligned_changes': len(changes),
                        'missing_passages': len(missing), 'conflicting_destinations': len(conflicts),
                        'held_records': len(held), 'extra_references': len(extras)},
            'aligned_changes': changes, 'missing_passages': missing, 'conflicts': conflicts,
            'held_records': held, 'resolutions': dispositions, 'extra_references': extras}


def build_audit(source, review_path, out):
    source, review_path, out = Path(source), Path(review_path), Path(out).resolve()
    if out.is_relative_to(source.resolve()) or out == review_path.resolve():
        raise ValueError('Audit must not overwrite its inputs')
    records, files = read_output(source)
    review = json.loads(review_path.read_text())
    if files != review['input_files']:
        raise ValueError('Translation snapshot changed; refresh the affected reviews')
    site, _ = load_site()
    site_refs = {':'.join(map(str, key)) for key in site}
    # Bind the reference text too, since numbering alone cannot prove alignment.
    site_hash = digest(json.dumps({':'.join(map(str, k)): v for k, v in site.items()}, ensure_ascii=False, sort_keys=True))
    if review['site_sha256'] != site_hash:
        raise ValueError('Site reference text changed; refresh the review')
    for relative, expected in review.get('repair_source_files', {}).items():
        path = (source.parent / relative).resolve()
        if not path.is_relative_to((source.parent / 'sources').resolve()):
            raise ValueError('Repair source path outside sources directory')
        if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
            raise ValueError(f'Repair source text changed: {relative}')
    result = align(records, site_refs, review)
    result['missing_reference_text'] = {ref: site[ref_order(ref)] for ref in result['missing_passages']}
    result['input_files'] = files
    result['site_sha256'] = site_hash
    result['review_sha256'] = hashlib.sha256(review_path.read_bytes()).hexdigest()
    result['usage'] = review['usage']
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n')
    return result


def export_aligned(source, audit_path, out):
    """Write a complete, separately numbered release; never mutate raw records."""
    source, audit_path, out = Path(source), Path(audit_path), Path(out).resolve()
    if out.is_relative_to(source.resolve()) or source.resolve().is_relative_to(out):
        raise ValueError('Release must be separate from raw translation output')
    if out.exists() and any(out.iterdir()):
        raise ValueError('Release destination must be empty')
    records, files = read_output(source)
    audit = json.loads(audit_path.read_text())
    if not audit['numbering_ready'] or files != audit['input_files'] or any(
        audit.get(key) for key in ('missing_passages', 'conflicts', 'held_records')
    ):
        raise ValueError('Numbering audit incomplete or source snapshot changed')
    site, codes = load_site()
    site_hash = digest(json.dumps({':'.join(map(str, k)): v for k, v in site.items()}, ensure_ascii=False, sort_keys=True))
    if audit['site_sha256'] != site_hash:
        raise ValueError('Release reference text changed')
    books = {book: {'schema': 1, 'book': code, 'translation': '日常訳第1版', 'chapters': {}} for book, code in codes.items()}
    changes = audit['aligned_changes']
    for key in sorted(site):
        book, ch, verse = key
        ref = ':'.join(map(str, key))
        text = changes[ref]['text'] if ref in changes else records.get(ref)
        if not text:
            raise ValueError(f'Missing release verse: {ref}')
        chapter = books[book]['chapters'].setdefault(str(ch), {'heading': '', 'verses': {}})
        chapter['verses'][str(verse)] = text
    for ref, change in changes.items():
        book, ch, verse = ref_order(ref)
        if verse == 0:
            books[book]['chapters'][str(ch)]['heading'] = change['text']
    out.mkdir(parents=True, exist_ok=True)
    hashes = {}
    for book, payload in books.items():
        filename = codes[book] + '.json'
        text = json.dumps(payload, ensure_ascii=False, separators=(',', ':')) + '\n'
        (out / filename).write_text(text)
        hashes[filename] = digest(text)
    manifest = {'schema': 1, 'translation': '日常訳第1版', 'verses': len(site),
                'chapters': len({k[:2] for k in site}), 'books': len(books), 'files': hashes,
                'audit_sha256': hashlib.sha256(audit_path.read_bytes()).hexdigest()}
    (out / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
    return manifest
